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 (Config → config,
AppConfig → appconfig). // 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:
// 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.
- 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 dependency —
provide NewRepo: no provider for parameter db (app.DB); add // di:provide or // di:param
- Ambiguous dependency —
provide X: parameter p (app.T) is ambiguous between A, B
- Dependency cycle —
dependency cycle: NewA -> NewB -> NewA
- Bad provider signature —
provider NewX must return (T) or (T, error)
- Bad projection —
di: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).