# sarulabs/di and the definition-based container for Go programs

> sarulabs/di is a dependency injection container for Go that creates objects on demand from definitions, resolves their dependencies through the container, and closes them when the container goes away. The interesting constraint is that its EnhancedBuilder rewrites the definitions you hand it.

**sarulabs/di** — Dependency injection container in go (golang)

- Repository: https://github.com/sarulabs/di
- Stars: 725 · Forks: 60
- Language: Go
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/sarulabs-di

## A container that owns construction, resolution and closing

sarulabs/di exists to handle the life cycle of objects in a Go program. The project documentation states that it creates them when they are needed, resolves their dependencies, and closes them properly when they are no longer used. That third responsibility is the one that distinguishes it from a simple registry, and it is the reason a container earns its keep in a program that opens connections, files or background workers.

The audience is Go developers who want dependency injection without an annotation system or a code generator. Everything is expressed in ordinary Go values, which is why the API surface is built around the Def structure and a builder rather than around registration annotations. The library also states that it is focused on performance, and the version history shows a release dedicated to major speed improvements in v2.5.0.

The trade-off is visible in the API. Retrieval returns an interface{} that the caller must cast, and the documented lookup order puts the definition pointer first, then the name, and the type last, with the documentation marking the latter two as slower. Nothing here is compile-time checked, so the container trades static guarantees for runtime flexibility.

## Definitions carry a Build function, a name and a type

The unit of registration is a definition, and the only thing it strictly requires is a Build function that creates the object.

```go
MyObjectDef := &di.Def{
    Build: func(ctn di.Container) (interface{}, error) {
        return &MyObject{}, nil
    },
}
```

Two optional setters make the object easier to retrieve later. SetName attaches a string, and SetIs attaches the type of the object:

```go
MyObjectDef.SetName("my-object")
MyObjectDef.SetIs((*MyObject)(nil))
```

The documentation also shows a constructor form, where the build function is passed to NewDef instead of being written into a struct literal, so the two styles are interchangeable.

Two error-handling details are worth remembering. If the Build function returns an error, that error reaches the caller of Get. If the Build function panics, the panic is recovered and behaves as if an error had been returned, so a faulty constructor cannot take the process down at the point of resolution.

The Build function receives the container, which is what makes dependency resolution work. Calling Get inside a Build function is documented as safe, and the same panic recovery applies.

## Lazy construction, and three ways to look an object up

Nothing is constructed until it is asked for. The container calls the Build function the first time Get is called and returns the same object afterwards, unless the definition sets its Unshared field to true, in which case each retrieval produces a fresh instance. The documentation states plainly that the three different lookups of the same definition return the same pointer, so sharing is the default and you opt out deliberately.

The three lookups differ in cost:

```go
// Either with the definition (recommended)
ctn.Get(MyObjectDef).(*MyObject)
// Or the name (which is slower)
ctn.Get("my-object").(*MyObject)
// Or the type (even slower)
ctn.Get(reflect.typeOf((*MyObject)(nil))).(*MyObject)
```

The recommendation to look up by definition pointer is the practical rule to follow. Note that the type lookup as printed writes reflect.typeOf, so check the exact name in the reflect package before copying that line.

The project also ships sibling accessors. The file layout shows containerGetter.go, containerGetterSafe.go, containerGetterHelpers.go and containerUnscopedGetter.go, which line up with the Get, SafeGet, Fill and unscoped retrieval entries in the table of contents. The visible documentation does not spell out how SafeGet differs from Get, or how Fill deals with a struct it was not given a way to change, so both belong in the list of things to read in the source before you rely on them.

## The EnhancedBuilder rewrites your definitions, so build once

There are two builders, and the project documentation tells you to use the EnhancedBuilder. It was introduced to add features that could not be added to the original Builder without breaking backward compatibility, which is the usual reason a library ends up with two constructors.

Registering definitions works as you would expect, and adding a definition with a name that already exists replaces the earlier one:

```go
builder, err := di.NewEnhancedBuilder()

// Adding a definition named "my-object".
err = builder.Add(&di.Def{
    Name: "my-object",
    Build: func(ctn di.Container) (interface{}, error) {
        return &MyObject{Value: "A"}, nil
    },
})
```

The limitations are the interesting part. Build can be called only once, after which it returns an error. The same definition cannot be used in two different EnhancedBuilder instances. And a definition should not be updated after it has been added. The reason given is that Build alters the definitions: it resets the definition fields to their value at the time the definition was added to the builder, which links each definition to that builder and to the container it produces.

That has a practical consequence for test code. If your tests construct a fresh container, they cannot reuse the same definition pointers that the production builder consumed, so the definitions have to be produced again. The documentation is explicit that the definitions belong to the builder.

## Scopes, lineage and the HTTP helpers live in their own files

Beyond definitions and retrieval, the repository is organised around a set of distinct concerns, and the file names make the design legible. scope.go holds the scope machinery, containerLineage.go the ancestry of containers, containerSlayer.go the teardown path for a container and the objects it holds, and http.go the HTTP helpers. The table of contents lists sections on the scope principle, scopes in practice, how scopes and dependencies interact, container deletion and unscoped retrieval, followed by HTTP helpers, examples and performance.

The scope model is the part to read most carefully before adopting the library, because it decides whether two callers share one instance. The top-level container is the natural home for singletons that must live for the whole process, and child containers are how you get a shorter-lived instance for a request or a job. The documentation also warns that dependencies and scopes interact, so a definition that resolves another definition inherits a lifetime decision made somewhere else in the graph.

The HTTP helpers are a notable inclusion for a container library. They exist to bridge the container to a server, which tells you the project is aimed at services with a request lifecycle rather than at small command line programs.

## Go 1.20 in go.mod, a v2 module path, and an MIT licence

The module declares its path with a major version suffix:

```go
module github.com/sarulabs/di/v2
```

```go
go 1.20
```

The README badge, on the other hand, advertises a minimum version of 1.16, so the two statements about the supported toolchain disagree and the go.mod directive is the one your build will actually enforce.

The only direct dependency is the test assertion library:

```go
require github.com/stretchr/testify v1.8.2
```

That is a light dependency footprint, which matters for a container that other libraries import. The remaining entries are indirect dependencies pulled in by testify.

The licence identifier is MIT, so the code can be reused in commercial and closed source projects provided the notice is kept. The repository is not archived and the last push was on 2026-08-29. The published releases are v2.5.2 on 2025-06-29, fixing issues in the SetIs and Get type handling, v2.5.1 on 2024-07-13 which added the ParentContainer function, and v2.5.0 on 2024-07-11 with major speed improvements. The gap between the last release and the last push is worth noting if you pin a version: the tip of master carries work that no tag contains.

## Conclusion

sarulabs/di suits Go services that need lazy construction, orderly closing of resources and per-scope instances, and that already accept a runtime cost in exchange for not wiring constructors by hand. It does not suit a library author or a small CLI whose dependency graph fits in three constructors. Before adopting, read the EnhancedBuilder restrictions in the project documentation, since Build mutates every definition you registered and the same definition cannot be reused in a second builder, and check the Go version your build target allows against the go 1.20 directive in go.mod.

## FAQ

### How do I create a container in sarulabs/di?

Create an EnhancedBuilder with di.NewEnhancedBuilder(), register your definitions with the Add method, then call Build to obtain a container. Objects are created lazily the first time they are requested through Get.

### Can I call Build more than once on an EnhancedBuilder?

No. The documentation states that Build can only be called once and returns an error afterwards, because it alters the definitions, resets their fields to the values they had when added, and links them to that builder and its container. The same definition also cannot be used in a second EnhancedBuilder.

### What happens if a Build function panics in sarulabs/di?

Panics inside Build functions are recovered and behave as if an error was returned. The same recovery applies when a Build function calls Get on the container to resolve one of its own dependencies.

### How do I get a fresh instance instead of a shared one in sarulabs/di?

Set the Unshared field of the definition to true. Without it the container calls Build only on the first Get and returns the same object afterwards, so the three documented lookups by definition, by name and by type all give the same pointer.

### Which Go version does sarulabs/di require?

The go.mod file declares go 1.20, while the README badge advertises a minimum of 1.16. The go.mod directive is what your build enforces, so target 1.20 or newer.

## Sources

- [Issues](https://github.com/sarulabs/di/issues)
- [License: MIT](https://github.com/sarulabs/di/blob/master/LICENSE)
- [README](https://github.com/sarulabs/di/blob/master/README.md)
- [Releases](https://github.com/sarulabs/di/releases)
- [sarulabs/di on GitHub](https://github.com/sarulabs/di)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/sarulabs-di
