easydi

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: May 20, 2026 License: MIT Imports: 0 Imported by: 0

README

easydi easydi

Reflection-free, compile-time dependency-injection code generator for Go.

Go Reference Go Report Card CI Release

easydi reads // di: comment annotations on your constructors and root types and generates a plain Go file containing a Container struct and a Build(<roots>) (*Container, error) function that wires everything in dependency order. There is no reflect at build or run time — the output is ordinary code you can read, diff, and step through in a debugger.

Install

go install github.com/ramory-l/easydi/cmd/easydi@latest
# or pin a release
go install github.com/ramory-l/easydi/cmd/easydi@v0.1.0

Quick start

A complete, self-contained example.

app/types.go

package app

// di:root
type Config struct {
	DB   DBConfig
	HTTP HTTPConfig
}

type DBConfig struct{ DSN string }
type HTTPConfig struct{ Addr string }

type Logger struct{ prefix string }

type DB struct{ dsn string }

func (d *DB) Ping() error { return nil }

type UserRepo interface {
	ByID(id int) (string, error)
}

type userRepo struct{ db *DB }

func (r *userRepo) ByID(id int) (string, error) { return "", nil }

type UserService struct {
	repo UserRepo
	log  *Logger
}

type Handler struct{ svc *UserService }

app/providers.go

package app

// di:provide
func NewLogger(
	// di:param Config.HTTP.Addr
	addr string,
) *Logger {
	return &Logger{prefix: addr}
}

// di:provide
func NewDB(
	// di:param Config.DB.DSN
	dsn string,
) (*DB, error) {
	db := &DB{dsn: dsn}
	if err := db.Ping(); err != nil {
		return nil, err
	}
	return db, nil
}

// di:provide
func NewUserRepo(db *DB) UserRepo {
	return &userRepo{db: db}
}

// di:provide
func NewUserService(repo UserRepo, log *Logger) *UserService {
	return &UserService{repo: repo, log: log}
}

// di:provide
// di:expose
func NewHandler(svc *UserService) *Handler {
	return &Handler{svc: svc}
}

Generate the container:

easydi gen -o app/di/easydi_gen.go -pkg di ./app

Generated app/di/easydi_gen.go (verbatim shape):

// Code generated by easydi. DO NOT EDIT.

package di

import (
	"example.com/mymod/app"
	"fmt"
)

type Container struct {
	NewLogger      *app.Logger
	NewDB          *app.DB
	NewUserRepo    app.UserRepo
	NewUserService *app.UserService
	NewHandler     *app.Handler
}

func Build(config app.Config) (*Container, error) {
	c := &Container{}
	c.NewLogger = app.NewLogger(config.HTTP.Addr)
	vNewDB, err := app.NewDB(config.DB.DSN)
	if err != nil {
		return nil, fmt.Errorf("provide NewDB: %w", err)
	}
	c.NewDB = vNewDB
	c.NewUserRepo = app.NewUserRepo(c.NewDB)
	c.NewUserService = app.NewUserService(c.NewUserRepo, c.NewLogger)
	c.NewHandler = app.NewHandler(c.NewUserService)
	return c, nil
}

func (c *Container) Exposed() []any {
	return []any{c.NewHandler}
}

Using it in main.go:

func main() {
	cfg := app.Config{
		DB:   app.DBConfig{DSN: os.Getenv("DB_DSN")},
		HTTP: app.HTTPConfig{Addr: ":8080"},
	}
	c, err := di.Build(cfg)
	if err != nil {
		log.Fatalf("build container: %v", err)
	}
	h := c.NewHandler // typed field, no casts
	_ = h
}

CLI

easydi gen [-o file] -pkg <name> <packages...>
Flag Default Meaning
-o easydi_gen.go Output path. Missing parent directories are created automatically.
-pkg (required) package clause for the generated file.
<packages...> Go package patterns to scan (e.g. ./..., ./app).

Because the generated file imports your provider packages, it must live in the same module (Go's internal-package rule). A typical place is an internal di package:

easydi gen -o internal/di/easydi_gen.go -pkg di ./...

go:generate

//go:generate go run github.com/ramory-l/easydi/cmd/easydi gen -o easydi_gen.go -pkg di ./...

Then go generate ./... regenerates the container as part of your build.

Annotations

Annotation Placement Meaning
// di:provide constructor func doc register the func as a graph node
// di:provide name=X constructor func doc same, with an explicit node name X
// di:root type declaration doc declare an external input to Build
// di:param <path> line directly above a parameter bind that parameter from a root projection, a provider node projection, or a pkg.Ident literal
// di:use <NodeName> line directly above a parameter bind that parameter to the specific provider node named NodeName (consumer-side selection; mutually exclusive with // di:param)
// di:live di:root type doc (with // di:root) switch that root's Build parameter to configProvider func() Root; easydi.Live[T] params off it are read late
// di:expose constructor func doc include this node in Exposed() []any

Wiring rules, by example

1. Resolve by type

Parameters with no // di:param are resolved by Go type. Exactly one provider must produce a matching type:

// di:provide
func NewClock() Clock { return realClock{} }

// di:provide
func NewScheduler(clk Clock) *Scheduler { // clk wired from NewClock
	return &Scheduler{clk: clk}
}

Matching is strict:

  • Concrete types must be identical (types.Identical).
  • An interface parameter is satisfied by any provider whose produced type implements it (types.Implements).
  • Pointers and values are distinct. There is no implicit & / *.
// di:provide
func NewDB() *DB { return &DB{} } // produces *DB

// di:provide
func NewRepo(db DB) *Repo { // wants DB (value) — NOT satisfied by *DB
	return &Repo{}
}
// easydi: provide NewRepo: no provider for parameter db (app.DB);
//         add // di:provide or // di:param

Fix by matching the pointer-ness (db *DB) or providing the value form.

2. Roots and di:param projections

A // di:root type becomes a parameter of Build. Its generated variable name is the lowercased type name (Configconfig, AppConfigappconfig). // di:param <path> projects a value out of a root:

// di:root
type Config struct {
	Auth AuthConfig
	DB   DBConfig
}
type AuthConfig struct{ Secret string }
type DBConfig struct{ Pool PoolConfig }
type PoolConfig struct{ Max int }

// di:provide
func NewSigner(
	// di:param Config.Auth.Secret      -> config.Auth.Secret
	secret string,
) *Signer { return &Signer{secret: secret} }

// di:provide
func NewPool(
	// di:param Config.DB.Pool.Max      -> config.DB.Pool.Max
	max int,
) *Pool { return &Pool{max: max} }

Path segments may also be zero-argument method calls (one segment per ., suffixed with ()):

type Infra struct{ db DB }
func (i Infra) GetDB() DB    { return i.db }
func (d DB) DSN() string     { return d.dsn }

// di:root
type App struct{ Infra Infra }

// di:provide
func NewStore(
	// di:param App.Infra.GetDB().DSN()  -> app.Infra.GetDB().DSN()
	dsn string,
) *Store { return &Store{dsn: dsn} }

Pass the whole root by naming it with no further path:

// di:provide
func NewThing(
	// di:param Config
	cfg Config,        // receives the entire `config` value
) *Thing { return &Thing{cfg: cfg} }

Root projections are type-checked: the projected type must be assignable to the parameter type, every field/method must exist, and the head must be a declared // di:root.

3. Package-qualified literals

If the head of a di:param path is not a declared root, it is treated as a package-qualified literal. It must be exactly two segments, no calls (pkg.Ident), emitted verbatim and type-checked when the generated file compiles:

import "net/http"

// di:provide
func NewFetcher(
	// di:param http.DefaultClient
	client *http.Client,
) *Fetcher { return &Fetcher{c: client} }

(For anything more complex — a function call, a constructed value — use a real // di:provide constructor instead.)

4. Error-returning providers

A provider returns either T or (T, error). Error returns are checked and wrapped with the node name:

// di:provide
func NewDB(
	// di:param Config.DB.DSN
	dsn string,
) (*DB, error) {
	return sql.Open("postgres", dsn)
}

Generated:

vNewDB, err := app.NewDB(config.DB.DSN)
if err != nil {
	return nil, fmt.Errorf("provide NewDB: %w", err)
}
c.NewDB = vNewDB

A provider that returns only error, or more than two results, is rejected at generation time.

5. di:expose

// di:expose adds a node to Exposed() []any, returned in dependency order. Useful for the small set of "entrypoint" objects an app actually hands to a server/runner:

// di:provide
// di:expose
func NewHTTPHandler(s *UserService) http.Handler { ... }

// di:provide
// di:expose
func NewGRPCServer(s *UserService) *grpc.Server { ... }
c, _ := di.Build(cfg)
for _, x := range c.Exposed() { // []any{c.NewHTTPHandler, c.NewGRPCServer}
	register(x)
}

If nothing is exposed, Exposed() returns nil.

6. name= — node names and collisions

The node name defaults to the function name and becomes the Container field name. Use name= when two providers would otherwise collide (e.g. two New funcs in different packages), or just for a clearer field name:

// di:provide name=PrimaryDB
func New( /* ... */ ) *DB { ... }   // -> c.PrimaryDB

// di:provide name=ReplicaDB
func New( /* ... */ ) *DB { ... }   // -> c.ReplicaDB

Note: name= only sets the node/field name. It is not a consumer-side selector. If two providers produce the same type and a parameter is resolved by type, that is reported as an ambiguity error — resolve it by giving the producers distinct types, projecting the value from a root with // di:param, or selecting a specific provider with // di:use <NodeName>.

7. Consumer-side selection with // di:use

When multiple providers produce a type that satisfies one parameter, by-type resolution is ambiguous and easydi will report an error. // di:use <NodeName> lets a consumer select the specific provider node to wire, by its node name (the di:provide name= value, or the function name when name= is omitted).

// di:provide name=TwitchHTTP
func NewTwitchHTTP() *http.Client { /* ... */ }

// di:provide name=HandlersAuth
func NewAuthHTTP() *http.Client { /* ... */ }

// di:provide
func NewTwitchAPI(
    // di:use TwitchHTTP
    client *http.Client,
) *TwitchAPI { return &TwitchAPI{c: client} }

// di:provide
func NewAuthService(
    // di:use HandlersAuth
    client *http.Client,
) *AuthService { return &AuthService{c: client} }

Rules:

  • // di:use must appear on its own line directly above the parameter it annotates (same placement as // di:param).
  • The named node must exist; its produced type must be assignable to the parameter under the same strict rules as by-type resolution (identical concrete types, or an interface the produced type implements; pointers ≠ values).
  • // di:param and // di:use on the same parameter is an error.
  • The selected edge participates in topological ordering and cycle detection like any other dependency.

8. Node-rooted projections

// di:param paths may also be rooted at a provider node name, projecting a field (or zero-arg method result) out of one specific provider. This composes with // di:provide name= when multiple providers produce the same type and a consumer needs just a slice of one of them:

type DBConfig struct{ DSN string }

// di:provide name=Primary
func NewPrimary() *DBConfig { return &DBConfig{DSN: "primary"} }

// di:provide name=Secondary
func NewSecondary() *DBConfig { return &DBConfig{DSN: "secondary"} }

// di:provide
func NewService(
    // di:param Primary.DSN
    dsn string,
) *Service { return &Service{dsn: dsn} }

Generated: c.NewService = app.NewService(c.Primary.DSN).

Single-segment // di:param NodeName (no projection) binds the whole produced value and is exactly equivalent to // di:use NodeName — both forms are supported.

Rules:

  • Path grammar is identical to root projections (fields + zero-arg method calls; head cannot be a call).
  • If a head name matches both a di:root and a provider node, codegen fails with ambiguous head … rename one.
  • easydi.Live[T] rooted at a provider node is rejected: provider results are singletons; use a // di:live root for live values.
  • The projected source node participates in topological ordering exactly like a di:use binding.

9. Live config with // di:live and easydi.Live[T]

Why this exists. Providers are singletons: Build calls each constructor once. A constructor that takes a // di:param projection freezes that value at construction. If your configuration changes at runtime (feature flags, log level, rotating OAuth settings — delivered by whatever mechanism you own), objects built from a frozen projection never see the new value. Rebuilding the graph to pick it up would destroy stateful nodes (open pools, caches, tickers) and break pointer identity. // di:live solves this without a rebuild and without a proxy: the consumer reads the value late, at use time.

Two pieces:

  1. // di:live on a // di:root config type. It changes that root's Build parameter from a value to a provider function: Build(config Config, …)Build(configProvider func() Config, …). Other roots are unchanged. You wire configProvider once — e.g. to a function that returns the latest loaded config.
  2. The declared parameter type decides per-parameter behavior. A plain value-typed di:param stays frozen (materialized once at Build). A parameter declared easydi.Live[T] is late-bound: easydi generates easydi.LiveOf(configProvider, projection), and the holder calls .Get() at the use site to observe the current value.
import "github.com/ramory-l/easydi"

// di:root
// di:live
type Config struct {
	TwitchOAuth TwitchOAuth
	LogLevel    string
}
type TwitchOAuth struct{ ClientID, ClientSecret string }

// di:provide name=TwitchRepository
func NewRepository(
	// di:param Config.TwitchOAuth       // same directive as a frozen param
	oauth easydi.Live[TwitchOAuth],      // only the TYPE changed -> late-bound
	// di:param Config.LogLevel
	logLevel string,                     // plain type -> frozen at Build
) *Repository {
	return &Repository{oauth: oauth, logLevel: logLevel}
}

func (r *Repository) Exchange(ctx context.Context, code string) error {
	c := r.oauth.Get() // fresh projection every call — reflects current config
	oc := &oauth2.Config{ClientID: c.ClientID, ClientSecret: c.ClientSecret}
	_, err := oc.Exchange(ctx, code)
	return err
}

Generated Build (the object is still constructed exactly once — never rebuilt):

func Build(configProvider func() Config, /* …other roots… */) (*Container, error) {
	c := &Container{}
	config := configProvider()           // frozen params projected once
	c.TwitchRepository = app.NewRepository(
		easydi.LiveOf(configProvider, func(root Config) TwitchOAuth { return root.TwitchOAuth }),
		config.LogLevel,
	)
	return c, nil
}

Wire it once at startup, e.g.:

c, err := di.Build(func() Config { return loadConfig() }, infra)

Rules:

  • // di:live is only valid together with // di:root on the same type.
  • easydi.Live[T] is only meaningful for a di:param projection rooted at the di:live root config. Using it without a di:live root, or with a path not rooted there, is a codegen error.
  • You cannot make a plain value-typed parameter live (a captured value cannot change without the holder reading it late). Opting a hot consumer in costs exactly one parameter-type change; stateful fields and object identity are untouched.
  • Without // di:live, generated output is byte-identical to before — fully backward compatible.
  • Live[T].Get() recomputes project(provider()) on every call (no caching); concurrency safety is exactly that of the configProvider you supply.

Lifecycle: Start and Close

Build only constructs your graph. Real services also have components that must be started (background workers, queue consumers, schedulers) and stopped cleanly on shutdown (flush buffers, close pools and clients).

Without help, the only thing that knows "which of these constructed objects must run, and in what order" is a hand-written aggregator — exactly the glue DI is meant to remove. The Lifecycle feature closes that gap: a node declares its own runtime needs by implementing an interface, and the generated container drives them in dependency order. No aggregator, no registry.

The interfaces

easydi exposes one tiny, framework-agnostic package:

import "github.com/ramory-l/easydi/lifecycle"

type Starter interface{ Start(ctx context.Context) error }
type Closer  interface{ Close(ctx context.Context) error }

Both are optional. Any node included in Exposed() (i.e. annotated // di:expose) that implements Starter and/or Closer is driven automatically; nodes that implement neither are skipped.

Generated behavior

Over the exposed nodes, in topological (dependency-first) order, easydi emits:

func (c *Container) Start(ctx context.Context) error
func (c *Container) Close(ctx context.Context) error
  • Start calls Start(ctx) on each exposed Starter in dependency order. If one fails, every exposed Closer constructed before it is closed in reverse order and the original error is returned. After Start returns a non-nil error the container is fully unwound — do not call Close.
  • Close calls Close(ctx) on each exposed Closer in reverse dependency order and returns errors.Join of all failures.

The ordering guarantee is the same one Build already uses: dependencies are started before their dependents and closed after them.

Example

// di:provide
// di:expose
func NewWorker(q Queue) *Worker { return &Worker{q: q} }

func (w *Worker) Start(ctx context.Context) error { go w.loop(ctx); return nil }
func (w *Worker) Close(ctx context.Context) error { return w.drain(ctx) }
func main() {
	c, err := di.Build(cfg, infra)
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()
	if err := c.Start(ctx); err != nil {
		log.Fatal(err) // already unwound; do NOT call Close here
	}
	defer c.Close(ctx)

	// ... serve ...
}

Generated API

For scanned packages, easydi emits exactly:

type Container struct { /* one field per node, named by node name, in dep order */ }

func Build(<one param per di:root, lowercased type name>) (*Container, error)

func (c *Container) Exposed() []any // di:expose nodes, in dep order; nil if none
func (c *Container) Start(ctx context.Context) error // di:expose Starters, dep order; unwinds & returns on first failure
func (c *Container) Close(ctx context.Context) error // di:expose Closers, reverse order; errors.Join of failures

Build constructs every node once, in topological order, threading dependencies through Container fields and propagating the first provider error.

Generated imports are path-sorted (not goimports-grouped into std / third-party blocks); the file is otherwise gofmt-formatted and compiles as plain Go.

Import aliasing

When the scanned packages require imports, easydi assigns each import a collision-safe, deterministic identifier:

  • If the package's base name is unique across all imports and is a valid Go identifier that is not a keyword, it is kept bare (e.g. app, http).
  • Otherwise a minimal-unique-suffix lowerCamelCase alias is derived from the import path (e.g. two packages both named http might become twitchHttp and vkliveHttp; two packages both named auth might become internalAuth and handlersAuth; a package named type gets an alias because it is a keyword).

The aliasing algorithm is deterministic — the same graph always produces the same alias assignments — and the output is gofmt-stable.

Errors

easydi fails generation with actionable messages, for example:

  • Missing dependencyprovide NewRepo: no provider for parameter db (app.DB); add // di:provide or // di:param
  • Ambiguous dependencyprovide X: parameter p (app.T) is ambiguous between A, B
  • Dependency cycledependency cycle: NewA -> NewB -> NewA
  • Bad provider signatureprovider NewX must return (T) or (T, error)
  • Bad projectiondi:param Config.Nope: no field Nope on app.Config

Constraints checklist

  • A // di:param comment must be on its own line directly above the parameter it annotates; put each annotated parameter on its own line.
  • Providers must return T or (T, error) — nothing else.
  • By-type matching is exact: identical concrete types, or an interface the produced type implements. Pointers ≠ values.
  • The generated file imports your provider packages, so it must live in the same Go module.
  • -pkg is required; -o defaults to ./easydi_gen.go.
  • easydi.Live[T] requires the projected root to be // di:live; the configProvider func() Root you pass to Build must be safe to call concurrently if any live consumer is used from multiple goroutines.

Limitations

  • di:param literals are limited to pkg.Ident (no calls/expressions).

Documentation

Overview

Package easydi is a reflection-free, compile-time dependency-injection code generator driven by // di: comment annotations. Consumer-side selection is available via the // di:use <NodeName> directive, or equivalently as a single-segment // di:param NodeName. A // di:param path whose head names a // di:provide node projects a field or zero-arg method out of that node's produced value (// di:param NodeName.Field.Sub). A // di:live root config plus easydi.Live[T] parameters opt selected di:param projections into late binding (read at use time, no graph rebuild).

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Live

type Live[T any] interface {
	// Get returns the current projection: project(provider()). It recomputes
	// on every call and never caches. Concurrency safety is exactly that of
	// the supplied provider; projections must be pure.
	Get() T
}

Live is a late-bound view of a value derived from the root config. Generated code produces Live values for di:param projections whose declared parameter type is easydi.Live[T]; the holder calls Get() at use time to observe the current configuration without the object graph being rebuilt.

func LiveOf

func LiveOf[Root, T any](provider func() Root, project func(Root) T) Live[T]

LiveOf builds a Live[T] from the user's root-config provider and a pure projection. Generated code calls this; users normally do not. The returned value adds no shared mutable state of its own.

Directories

Path Synopsis
cmd
easydi command
Command easydi generates a compile-time DI container from // di: annotations.
Command easydi generates a compile-time DI container from // di: annotations.
internal
annotation
Package annotation parses a single easydi `// di:` directive line.
Package annotation parses a single easydi `// di:` directive line.
gen
Package gen emits the easydi container source file.
Package gen emits the easydi container source file.
loader
Package loader wraps go/packages with the modes easydi needs.
Package loader wraps go/packages with the modes easydi needs.
parampath
Package parampath parses and validates an easydi di:param path.
Package parampath parses and validates an easydi di:param path.
resolver
Package resolver builds the typed easydi dependency graph.
Package resolver builds the typed easydi dependency graph.
scanner
Package scanner walks loaded packages and extracts easydi providers/roots.
Package scanner walks loaded packages and extracts easydi providers/roots.
topo
Package topo topologically sorts the easydi graph.
Package topo topologically sorts the easydi graph.
Package lifecycle defines the optional Start/Close interfaces that easydi's generated Container calls on di:expose nodes.
Package lifecycle defines the optional Start/Close interfaces that easydi's generated Container calls on di:expose nodes.

Jump to

Keyboard shortcuts

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