Skip to content
wiki edited this page Sep 4, 2026 · 1 revision

dix — Dependency Injection eXperience

A reflection-based dependency injection container for Go, in roughly 900 lines of implementation. It is the container behind rex, but it has no dependency on the framework and works standalone.

go get github.com/kryovyx/dix

Requires Go 1.27+.

What it does

  • Four registration kinds — Singleton, Scoped, Transient, Instance
  • Child scopes with automatic Close() of everything scoped that has one
  • Resolution by concrete type or by interface
  • Every failure is a sentinel error, so callers branch with errors.Is
  • Safe for concurrent registration and resolution

What it deliberately does not do

No struct-tag injection, no constructor auto-wiring, no cycle detection, no named bindings. A factory asks the resolver for what it needs, in Go, and you can read the wiring by reading the code. See Limitations.

Sixty seconds

package main

import (
	"fmt"

	"github.com/kryovyx/dix"
)

type Database struct{ DSN string }
type UserRepo struct{ DB *Database }

func main() {
	c := dix.New()

	// Built once, on first resolve, shared forever.
	_ = c.Singleton(func() *Database {
		return &Database{DSN: "postgres://localhost/app"}
	})

	// Built once per scope. The Resolver it receives IS the scope.
	_ = c.Scoped(func(r dix.Resolver) *UserRepo {
		var db *Database
		if err := r.Resolve(&db); err != nil {
			panic(err)
		}
		return &UserRepo{DB: db}
	})

	scope := c.NewScope()
	defer scope.Close()

	var repo *UserRepo
	if err := scope.Resolve(&repo); err != nil {
		panic(err)
	}
	fmt.Println(repo.DB.DSN)
}

Two things in that snippet are the whole design in miniature:

  1. UserRepo is resolved from the scope, not from c. Resolving a scoped type from the root container returns ErrScopedFromRoot and builds nothing — a scoped value with no owning scope is a value nothing will ever close.
  2. The factory takes a dix.Resolver and uses that, not the captured c. Closing over the container would resolve *Database from the root, which is fine for a singleton and wrong for anything scoped.

The three interfaces

type Resolver interface {
	Resolve(target any) error     // *target = the dependency
	ResolveAll(target any) error  // append everything resolvable
}

type Container interface {
	Resolver
	Singleton(factory any) error
	Scoped(factory any) error
	Transient(factory any) error
	Instance(v any) error
	Unbind(v any) (bool, error)
	NewScope() Scope
}

type Scope interface {
	Resolver
	Close() error
}

Resolver is the one most code should ask for. A handler, a health check and a factory all need to read dependencies; only wiring code needs to register.

Note for extension authors. Do not import dix to name these types. rextension/di declares the identical contract and rextension re-exports it, so an extension writes rextension.Resolver and never names an implementation. See rextension → Dependency Injection.

Where to go next

If you want to know Read
Which lifetime to pick Lifetimes
What a valid factory looks like Registration
How interface resolution picks a match Resolution
When things get closed Scopes
What an error means Errors
Whether you can register from a goroutine Concurrency
Patterns for requests, tests, and cleanup Recipes

Clone this wiki locally