-
Notifications
You must be signed in to change notification settings - Fork 0
Recipes
func wire(c dix.Container, cfg *Config) error {
db, err := sql.Open("postgres", cfg.DSN)
if err != nil {
return fmt.Errorf("open db: %w", err) // failable work happens here
}
return errors.Join(
c.Instance(cfg),
c.Instance(&Database{Pool: db}),
c.Singleton(newUserRepo),
c.Scoped(newUnitOfWork),
c.Transient(newIDGenerator),
)
}Outside rex, a scope per request looks like this:
func handler(c dix.Container) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
scope := c.NewScope()
defer func() {
if err := scope.Close(); err != nil {
log.Printf("scope cleanup: %v", err)
}
}()
var svc *UserService
if err := scope.Resolve(&svc); err != nil {
http.Error(w, "internal", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(svc.List(r.Context()))
}
}In rex this is already done for you — ctx.Resolver() is the request scope.
The cleanest use of scoped cleanup: make the safe outcome the default one.
type Tx struct {
tx *sql.Tx
committed bool
}
func (t *Tx) Commit() error { t.committed = true; return t.tx.Commit() }
// Close runs when the scope ends.
func (t *Tx) Close() error {
if t.committed {
return nil
}
return t.tx.Rollback()
}
c.Scoped(func(r dix.Resolver) *Tx {
var db *Database
if err := r.Resolve(&db); err != nil {
panic(err)
}
raw, err := db.Pool.Begin()
if err != nil {
panic(err)
}
return &Tx{tx: raw}
})A handler that returns early — a validation failure, a panic recovered upstream — rolls back without anyone remembering to.
type HealthCheck interface {
Name() string
Check(context.Context) error
}
c.Singleton(func() *DBCheck { return &DBCheck{} })
c.Singleton(func() *RedisCheck { return &RedisCheck{} })
var checks []HealthCheck
if err := c.ResolveAll(&checks); err != nil { ... }ResolveAll wants a pointer to a slice of interfaces. Note this is the one
place where several registrations satisfying one interface is intended rather
than ambiguous — Resolve would reject exactly this set.
type ReadDB struct{ *sql.DB }
type WriteDB struct{ *sql.DB }
c.Instance(&ReadDB{replica})
c.Instance(&WriteDB{primary})func TestUserService(t *testing.T) {
c := dix.New()
c.Instance(&StubRepo{Users: []User{{ID: "1"}}}) // registers *StubRepo
c.Singleton(newUserService) // resolves UserRepo iface
var svc *UserService
if err := c.Resolve(&svc); err != nil {
t.Fatal(err)
}
// ...
}Build a fresh container per test. There is no global state to reset, and a shared container across tests reintroduces exactly the cross-test leakage the package-level registries elsewhere in this ecosystem were removed to avoid.
// An extension registered a default; the application wants its own.
if removed, err := c.Unbind(defaultLogger); err != nil {
return err
} else if !removed {
// nothing was registered under that concrete type — usually a sign the
// default is a different type than you assumed
}
_ = c.Instance(myLogger)Unbind keys on the exact dynamic type of the value passed, so pass a value
of the type that was actually registered, not the interface you hold it in.
Resolution errors otherwise surface on the first request that needs the missing
piece. A smoke resolve in main moves that to boot:
func verify(c dix.Container) error {
scope := c.NewScope()
defer scope.Close()
var (
users *UserService
orders *OrderService
)
return errors.Join(
scope.Resolve(&users),
scope.Resolve(&orders),
)
}Use a scope, not the container, or every scoped dependency reports
ErrScopedFromRoot and the check fails for the wrong reason.
dix — Dependency Injection eXperience · MIT · © 2026 Kryovyx · pre-1.0, interfaces may change
Concepts
Reference
Practice
Ecosystem