loom

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 18, 2026 License: MIT Imports: 4 Imported by: 3

README

Loom

English | 简体中文

Loom is a compile-time dependency injection tool for Go.

You declare a dependency graph with loom.Graph, ordinary constructor functions, and a small marker API. loom generate reads that declaration and writes plain Go that calls your constructors in the right order. The generated code contains no reflection, no service locator, and no runtime container — just a function.

var AppGraph = loom.Graph[*Application](
    loom.WithContext(),
    loom.Provide(NewConfig),
    loom.Provide(NewLogger),
    loom.Provide(NewDatabase),
    loom.Provide(NewRepository[User]),
    loom.Provide(NewRepository[Article]),
    loom.As[UserStore](NewUserStore),
    loom.Provide(NewUserService),
    loom.Provide(NewHTTPServer),
    loom.Provide(NewApplication),
)
loom generate ./...
// Code generated by loom. DO NOT EDIT.

func InitApp(ctx context.Context) (*Application, *loom.Lifecycle, error) {
    lifecycle := loom.NewLifecycle()

    config := NewConfig()

    logger := NewLogger(config)

    database, databaseCleanup, err := NewDatabase(config)
    if err != nil {
        return nil, nil, errors.Join(err, lifecycle.Rollback(ctx))
    }
    lifecycle.AddCleanup(databaseCleanup)

    userRepository := NewRepository[User](database)
    articleRepository := NewRepository[Article](database)
    userStore := NewUserStore(userRepository, logger)
    userService := NewUserService(userStore, logger)
    httpServer := NewHTTPServer(lifecycle, config, logger, userService)
    application := NewApplication(config, logger, database, httpServer, userService)

    return application, lifecycle, nil
}

Two Repository[...] instances are built from one *Database, interfaces are bound explicitly, and resources are released in reverse order.

Why Loom

Compile-time The graph is resolved by go/types, not at run time.
Generics first Repository[User] and Repository[Article] are distinct dependency keys, decided with types.Identical.
No reflection Runtime code has no reflect, no map[reflect.Type], no container.
Readable output Generated code looks like what you would write by hand, with meaningful variable names.
Great diagnostics Missing providers, cycles, and duplicates come with the full dependency path and source locations.
gopls friendly The graph declaration is an ordinary package-level variable; nothing hides behind build tags.
Lifecycle support Ordered start/stop hooks and constructor cleanups, with rollback on failure.
Deterministic The same input always produces byte-identical output.
No stub files Graphs live in ordinary files, so refactoring and find-references work.

Install

Pin the generator to your module, so everyone and CI run the same version:

go get -tool github.com/Xwudao/loom/cmd/loom@latest
go tool loom generate ./...

Or install the binary once:

go install github.com/Xwudao/loom/cmd/loom@latest

loom is designed for Go 1.25 and newer (it is built and tested on Go 1.27).

Coming from Wire

Wire Loom
wire.NewSet(a, b) loom.Module(loom.Provide(a), loom.Provide(b))
wire.Build(...) in a wireinject stub loom.Graph[T](...) in an ordinary file
wire.Bind(new(I), new(*C)) loom.As[I](NewC)
injector (T, func(), error) (T, *loom.Lifecycle, error)
defer cleanup() defer lifecycle.Stop(ctx)

Two differences are worth calling out.

As replaces Provide, it does not accompany it. loom.As[I](NewC) registers NewC and exposes the same instance as I, so listing both is a duplicate provider. Loom says so directly:

loom: multiple providers found for *store
providers:
    internal/app/providers.go:33:15  NewStore()
    internal/app/providers.go:34:17  NewStore()

hint:
    loom.As[Store](NewStore) already provides both *store and Store; remove the separate loom.Provide(NewStore)

There is no stub file. Wire kept its injectors behind a wireinject build tag, so the call site only type-checked after generation. A Loom graph is an ordinary package-level variable: gopls, go vet and go test all see it. The trade-off is that on the very first run the initializer a call site refers to does not exist yet; loom generate recognises exactly the undefined: InitApp errors it is about to fix and still reports every other type error.

Quick start

package app

import "github.com/Xwudao/loom"

type Config struct{ DSN string }
type DB struct{ DSN string }
type App struct{ DB *DB }

func NewConfig() *Config       { return &Config{DSN: "memory://"} }
func NewDB(cfg *Config) *DB    { return &DB{DSN: cfg.DSN} }
func NewApp(db *DB) *App       { return &App{DB: db} }

// AppGraph is the graph declaration. The generated function is InitApp.
var AppGraph = loom.Graph[*App](
    loom.Provide(NewConfig),
    loom.Provide(NewDB),
    loom.Provide(NewApp),
)
$ loom generate ./...
loom: wrote app/loom_gen.go ([InitApp])
app, lifecycle, err := InitApp()
if err != nil {
    return err
}
if err := lifecycle.Start(ctx); err != nil {
    return err
}
defer lifecycle.Stop(shutdownCtx)
return app.Run(ctx)

Add //go:generate go run github.com/Xwudao/loom/cmd/loom generate to a file to wire generation into go generate ./....

Providers

A provider is a package-level function (or a package-level variable of function type) with one of these shapes:

func NewThing(...) T
func NewThing(...) (T, error)
func NewThing(...) (T, Cleanup)          // Cleanup = func(context.Context) error
func NewThing(...) (T, Cleanup, error)

Cleanup may also be func(), func() error, or func(context.Context); Loom adapts it. Variadic constructors are supported: func NewThing(opts ...string) is resolved from a []string provider and called as NewThing(opts...). Multi-value providers (func() (A, B)) are intentionally not supported — split them into two providers.

Register a provider with:

loom.Provide(NewDB)

Generics

Generic constructors must be instantiated, which makes the type argument part of the dependency key:

func NewRepository[T any](db *DB) *Repository[T]

loom.Provide(NewRepository[User])     // provides *Repository[User]
loom.Provide(NewRepository[Article])  // provides *Repository[Article]

A constructor that takes *Repository[User] will never receive the Article one. Loom compares types with go/types, not with strings, so aliases, pointer generics, and nested generics behave exactly as the compiler sees them.

Interface binding

Bind a constructor's result to an interface explicitly:

loom.As[UserStore](NewUserStore)

The generator verifies that NewUserStore's result implements UserStore, and reports a diagnostic at the call site when it does not:

loom: provider NewUserStore returns *httpserver.Store, which does not implement UserStore
at:
    internal/user/providers.go:22:2

The concrete type remains available too, so As never creates a second instance.

A graph may also expose a constructor that a module already provides. The binding stays in the graph, which matters when the interface lives in a package that imports the module — putting As in the module would close an import cycle:

var StoreModule = loom.Module(
    loom.Provide(NewStore),
)

// iface imports store, so the binding cannot live in StoreModule.
var AppGraph = loom.Graph[*App](
    StoreModule,
    loom.As[iface.Finisher](store.NewStore),
    loom.Provide(NewApp),
)

Listing one constructor twice in the same declaration is still reported, since that is a redundant line rather than a statement about what the graph exposes.

Modules

Group providers into reusable sets. Modules nest, and including the same module twice is a no-op.

var DatabaseModule = loom.Module(
    loom.Provide(NewDatabase),
    loom.Provide(NewRepository[User]),
    loom.Provide(NewRepository[Article]),
)

var ServiceModule = loom.Module(
    DatabaseModule,
    loom.As[UserStore](NewUserStore),
    loom.Provide(NewUserService),
)

Supply

Inject an existing value with loom.Supply. The expression is reproduced in the generated code, so it must be relocatable (identifiers, qualified names, literals, calls):

loom.Supply(config)
loom.Supply(Version{Major: 1, Minor: 2})

Note that Go evaluates the argument once during package initialization and again inside the generated initializer. Assign side-effecting expressions to a package-level variable first.

Lifecycle

*loom.Lifecycle is injectable. Register start/stop callbacks for long-lived services:

func NewHTTPServer(lc *loom.Lifecycle, cfg *Config) *HTTPServer {
    server := &HTTPServer{Addr: cfg.Addr}
    lc.Append(loom.Hook{
        OnStart: func(ctx context.Context) error { return server.Listen(ctx) },
        OnStop:  func(ctx context.Context) error { return server.Close() },
    })
    return server
}

Hooks run in registration order on Start and in reverse on Stop. If a hook fails to start, already-started hooks and cleanups are rolled back.

context.Context is injectable in two ways. loom.WithContext() threads the caller's context through, adding a leading ctx parameter to the generated function:

var AppGraph = loom.Graph[*App](
    loom.WithContext(),
    ...
)

Or a graph supplies it like any other dependency, which is what a Wire injector with a ProvideContext function does. An explicit provider wins over WithContext, so loom.Provide(ProvideContext) keeps the generated signature unchanged.

Cleanup and rollback

A constructor may return a cleanup alongside its value. Loom registers it with the lifecycle, so:

  • on a construction failure, everything built so far is released in reverse order (Lifecycle.Rollback);
  • on Lifecycle.Stop, started hooks stop first, then cleanups run, all in reverse registration order.

Do not return a cleanup and register an OnStop hook for the same resource.

The generated function

Every graph produces one function, named after the graph variable:

Declaration Generated function
var AppGraph = loom.Graph[*App](...) InitApp
var workerGraph = loom.Graph[*Worker](...) InitWorker
var AppGraph = loom.Graph[*App](loom.Name("BuildApp"), ...) BuildApp

The signature is stable:

func InitApp() (*App, *loom.Lifecycle, error)
// with loom.WithContext():
func InitApp(ctx context.Context) (*App, *loom.Lifecycle, error)

Generated code is written to loom_gen.go, gofmt-formatted, and only rewritten when its content actually changes.

Bootstrapping and regeneration

Loom has no stub file, so on a first run the initializer does not exist yet and every call site of it is reported by the type checker as undefined. That is expected, not an error: loom generate tolerates undefined: InitApp for the exact function names it is about to create, so you can write the graph and its call site in the same commit. Any other error is still reported.

Generation is all-or-nothing: every package is resolved and rendered before anything is written, so a diagnostic in one package cannot leave others half-generated. If the graph is valid but a symbol is out of date in a way the type checker cannot see, the generated call site fails to compile, which is the backstop.

Variables are named after the type they hold (Repository[User] becomes userRepository). When that name is already spoken for, Loom prefers an explicit package-qualified name over a numeric suffix, in these cases:

Provider result Name Why
*user.Repository userRepository free
*data.Data dataData data is the package's own import alias
*cmd.MainApp, graph named mainApp cmdMainApp mainApp is the function itself
Repository[User] twice userRepository2 already descriptive; suffix is enough

CLI

loom generate ./...        generate initializers (default ./...)
loom graph ./cmd/server    print the dependency graph
loom graph -format=dot ./cmd/server | dot -Tsvg > graph.svg
loom graph -format=json ./cmd/server
loom skills                  # install the agent skill into .agents/skills/loom
loom version
loom help

loom skills finds the nearest project .agents/skills directory (so it also works from a subdirectory) and installs the Loom usage skill at .agents/skills/loom. The skill includes a separate Wire migration reference.

loom generate -dry-run reports what would change without writing, and exits non-zero when a committed generated file is out of date, so it can gate CI:

$ loom generate -dry-run ./...
loom: internal/app/loom_gen.go out of date
loom: 1 generated file(s) out of date; run 'loom generate'
$ loom graph ./examples/full
InitApp  (examples/full/graph.go:22)
*Application
├── AppName
├── *Config
├── *Logger
│   └── *Config  (shared)
├── *Database
│   └── *Config  (shared)
├── *HTTPServer
│   ├── *loom.Lifecycle
│   ├── *Config  (shared)
│   └── *UserService
│       └── UserStore
│           └── *Repository[User]
└── *ArticleService
    └── *Repository[Article]

Diagnostics

Missing providers, cycles, and duplicates report the whole path:

loom: cannot build *App

dependency path:
    *App
      -> *UserService
      -> UserStore  (missing)

providers:
    internal/app/app.go:14:6       NewApp(*UserService)
    internal/user/service.go:13:6  NewUserService(UserStore)

hint:
    provider NewUserStore returns *userStore, which implements UserStore;
    bind it with loom.As[UserStore](NewUserStore)

What Loom is not

Loom never resolves dependencies at runtime. There is no container, no Resolve, no Get, and no reflection. The only runtime primitive is loom.Lifecycle, an ordered list of hooks and cleanups.

Version 0.1 deliberately omits named/keyed dependencies, optional dependencies, value groups, decorators, scopes (transient/request), and runtime replacement.

Repository layout

loom/
├── cmd/loom/            CLI
├── internal/
│   ├── load/            go/packages loading (one types universe)
│   ├── parse/           graph discovery and IR construction
│   ├── model/           providers, graphs, type index
│   ├── resolve/         topological resolution and cycle detection
│   ├── codegen/         deterministic Go emission
│   ├── diag/            diagnostics
│   └── gen/             orchestration + golden/benchmark tests
├── examples/            basic, generic, interface, lifecycle, rollback, full
├── lifecycle.go         Hook, Cleanup, Lifecycle
└── marker.go            Provide, As, Supply, Module, Graph, Name, WithContext

See docs/design.md for the design decisions behind the marker API, the type model, and the lifecycle semantics. See docs/compatibility.md for the API and generated-code compatibility policy.

Development

go test ./...
go vet ./...
go generate ./...   # regenerate examples and commit the result

Documentation

Overview

Package loom is a compile-time dependency injection toolkit for Go.

Dependency graphs are declared with Graph and ordinary constructor functions; the `loom` command reads those declarations and generates plain Go initialization code. Nothing about graph resolution happens at runtime: the generated code calls constructors directly.

The only runtime primitive shipped by this package is Lifecycle, an ordered list of start/stop hooks plus constructor cleanups. It exists so that generated applications can start and stop long-lived services in the right order. It is not a container and performs no dependency lookup.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrAlreadyStarted is returned by Start when the lifecycle is already
	// running.
	ErrAlreadyStarted = errors.New("loom: lifecycle is already started")
	// ErrAlreadyStopped is returned by Start when the lifecycle has been
	// stopped. Lifecycles are single-use and cannot be restarted.
	ErrAlreadyStopped = errors.New("loom: lifecycle is already stopped")
	// ErrStartFailed is returned by Start when a previous Start attempt failed.
	ErrStartFailed = errors.New("loom: lifecycle failed to start")
)

Sentinel errors returned by Lifecycle.Start.

Functions

This section is empty.

Types

type Cleanup

type Cleanup func(context.Context) error

Cleanup releases a resource acquired while a value was constructed.

A provider may return a Cleanup alongside its value:

func NewDB(cfg *Config) (*DB, loom.Cleanup, error)

Generated code registers each cleanup with the Lifecycle as soon as the constructor returns. Cleanups run in reverse registration order: during Lifecycle.Rollback when construction fails, or as part of Lifecycle.Stop on normal shutdown.

type GraphDef

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

GraphDef is the value produced by a Graph declaration. It is a compile-time marker: the generated initializer does not reference it.

func Graph

func Graph[T any](opts ...Option) GraphDef[T]

Graph declares the dependency graph used to build T.

// AppGraph wires the application.
var AppGraph = loom.Graph[*App](
	loom.Provide(NewConfig),
	loom.Provide(NewDB),
	loom.Provide(NewApp),
)

Graph must be assigned to a package-level variable. The generated initializer is named after that variable: AppGraph produces InitApp. Use Name to override the name.

type Hook

type Hook struct {
	// OnStart runs when the lifecycle starts. A nil OnStart is a no-op.
	OnStart func(context.Context) error
	// OnStop runs when the lifecycle stops. A nil OnStop is a no-op.
	OnStop func(context.Context) error
}

Hook couples an optional start callback with an optional stop callback.

Hooks registered with Lifecycle.Append run in registration order on Lifecycle.Start and in reverse registration order on Lifecycle.Stop.

type Lifecycle

type Lifecycle struct {
	// contains filtered or unexported fields
}

Lifecycle is the runtime primitive used by generated code to start and stop an application in dependency order.

The zero value is not ready for use; construct one with NewLifecycle. Lifecycle is safe for concurrent use by multiple goroutines.

func NewLifecycle

func NewLifecycle() *Lifecycle

NewLifecycle returns an empty, initialized lifecycle.

func (*Lifecycle) AddCleanup

func (l *Lifecycle) AddCleanup(c Cleanup)

AddCleanup registers a constructor cleanup. Cleanups run in reverse registration order, after any stop hooks that were started.

AddCleanup must be called before Start. Calling it once Start has begun panics. A nil cleanup is ignored, which lets generated code register a constructor's cleanup unconditionally.

func (*Lifecycle) Append

func (l *Lifecycle) Append(h Hook)

Append registers a hook. Hooks are started in registration order and stopped in reverse registration order.

Append must be called before Start. Calling it once Start has begun panics, because a hook registered concurrently with startup cannot reliably receive its OnStart call.

func (*Lifecycle) Rollback

func (l *Lifecycle) Rollback(ctx context.Context) error

Rollback releases resources acquired during a failed construction. It runs only registered cleanups, in reverse registration order, and never runs OnStop hooks (nothing was started). Generated code calls Rollback when a constructor returns an error.

Rollback ignores context cancellation so that a failed shutdown does not abandon still-open resources. It is a no-op unless the lifecycle is still initialized, so a later Stop cannot release the same resources twice.

func (*Lifecycle) Start

func (l *Lifecycle) Start(ctx context.Context) error

Start runs every registered OnStart hook in registration order.

If a hook fails or ctx is canceled, Start rolls back: it runs Stop hooks and cleanups for everything started so far, in reverse registration order, and returns the start error joined with any rollback errors. A lifecycle whose start failed cannot be started again.

func (*Lifecycle) State

func (l *Lifecycle) State() string

State reports the current lifecycle state. It is intended for diagnostics and tests.

func (*Lifecycle) Stop

func (l *Lifecycle) Stop(ctx context.Context) error

Stop runs registered OnStop hooks and cleanups in reverse registration order. Hooks are stopped only if their OnStart ran successfully; cleanups always run.

Errors from individual hooks are aggregated with errors.Join so that one failing hook does not prevent the rest of the shutdown. If ctx is canceled mid-shutdown, Stop records ctx.Err() and stops running further entries.

Stop is idempotent: calling it again after the lifecycle has stopped returns nil.

type ModuleDef

type ModuleDef struct {
	// contains filtered or unexported fields
}

ModuleDef is the value produced by a Module declaration. Like GraphDef it is a compile-time marker that generated code does not reference.

func Module

func Module(opts ...Option) ModuleDef

Module groups providers so that a related set can be declared once and included in several graphs. Modules may contain other modules.

type Option

type Option interface {
	// contains filtered or unexported methods
}

Option is a compile-time marker accepted by Graph and Module.

Option is a sealed interface: only this package can implement it, so graph declarations cannot be extended with arbitrary values.

func Name

func Name(name string) Option

Name sets the name of the function generated for a graph.

var appGraph = loom.Graph[*App](loom.Name("BuildApp"), ...)

func WithContext

func WithContext() Option

WithContext makes the generated initializer accept a context.Context and makes context.Context an injectable dependency.

func InitApp(ctx context.Context) (*App, *loom.Lifecycle, error)

Prefer doing I/O in a lifecycle OnStart hook; use WithContext when a constructor genuinely needs a context, such as a database dialer.

type Provider

type Provider struct {
	// contains filtered or unexported fields
}

Provider marks a constructor as a provider. Construct one with Provide, As, or Supply; the zero value is meaningless.

func As

func As[I any, F any](ctor F) Provider

As registers ctor as a provider of the interface I.

loom.As[UserStore](NewUserStore)

The generator verifies that the constructor's result implements I and reports a diagnostic at the provider's source location if it does not. The constructor's concrete result type is provided as well, so a binding never duplicates an instance.

func Provide

func Provide[F any](ctor F) Provider

Provide registers ctor as a provider of the value it returns.

ctor must be a package-level function (or a package-level variable holding a function) matching one of:

func(...) T
func(...) (T, error)
func(...) (T, Cleanup)
func(...) (T, Cleanup, error)

Generic constructors must be instantiated explicitly:

loom.Provide(NewRepository[User])

func Supply

func Supply[T any](value T) Provider

Supply registers an existing value as a provider.

loom.Supply(config)

The expression must be reproducible in generated code: identifiers, package qualified names, field selections, literals, composite literals, and calls whose operands are themselves reproducible. Note that Go evaluates the expression once during package initialization and again inside the generated initializer; assign side-effecting expressions to a package-level variable first.

Directories

Path Synopsis
cmd
loom command
Command loom generates dependency-injection code for Go programs.
Command loom generates dependency-injection code for Go programs.
examples
full
Package full is the end-to-end loom example: generics, an interface binding, modules, supply, lifecycle hooks, and constructor cleanups in one graph.
Package full is the end-to-end loom example: generics, an interface binding, modules, supply, lifecycle hooks, and constructor cleanups in one graph.
internal
codegen
Package codegen renders a resolved plan into gofmt-compatible Go source.
Package codegen renders a resolved plan into gofmt-compatible Go source.
diag
Package diag renders human-friendly diagnostics for graph problems.
Package diag renders human-friendly diagnostics for graph problems.
gen
Package gen wires loading, parsing, resolving, and code generation into a single operation.
Package gen wires loading, parsing, resolving, and code generation into a single operation.
load
Package load wraps go/packages so that the rest of Loom works with a single, consistent go/types universe.
Package load wraps go/packages so that the rest of Loom works with a single, consistent go/types universe.
model
Package model holds Loom's intermediate representation: providers and graphs expressed in terms of go/types, never in terms of strings or AST nodes alone.
Package model holds Loom's intermediate representation: providers and graphs expressed in terms of go/types, never in terms of strings or AST nodes alone.
parse
Package parse discovers loom.Graph declarations and turns them into the model IR.
Package parse discovers loom.Graph declarations and turns them into the model IR.
resolve
Package resolve turns a parsed graph into a topologically ordered build plan.
Package resolve turns a parsed graph into a topologically ordered build plan.

Jump to

Keyboard shortcuts

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