1. Manual DI and the composition root
DI (Dependency Injection) sounds like we’re about to raise some “enterprise architecture” and summon a shaman with a dependency container. In reality, in Go it’s much more down-to-earth: manual DI is when you create dependencies outside and pass them explicitly into whatever will use them. Not “I spun up a database inside the function,” but “in main I created an implementation and handed it to the service.”
The most important idea: DI is not about pretty diagrams; it’s about controlling coupling. When dependencies are created inside business logic, the code becomes hard to test, hard to extend, and easy to accidentally “nail” to a specific implementation. When dependencies are passed in from the outside, it becomes clear who is responsible for what, and the compiler helps you keep the architecture consistent instead of fighting it.
A dependency in Go is not only “a database”
Beginners often think a dependency is something big and scary: a DB, the network, queues. But in Go, a dependency is anything your code “uses” but should not “create” inside itself. It can be a task store, an ID generator, a writer for output, a time source, config, and so on. The more honestly you acknowledge this, the simpler your code becomes.
In our learning app (a mini task tracker), we’ll consider at least the task store and the place we write output to as dependencies. And here’s a useful habit right away: if a function accepts context.Context, then all dependencies that do work (I/O, storage) should accept ctx as well, so an operation cancellation/timeout can propagate down the chain.
A small “map” to orient yourself:
| What it is | Example | Why it’s a dependency |
|---|---|---|
| Storage | |
You can swap in-memory for a file/DB without changing the use case |
| Output | |
You can write to stdout, a buffer, a file—without rewriting the logic |
| Operation context | |
Propagates cancellation/deadlines down the call stack |
Why creating dependencies inside logic is a trap
Let’s start with an anti-example. It’s realistic: you’re writing a function, you need storage, and you go “I’ll just do it quickly.”
package app
import "example.com/myapp/adapters/memstore"
func AddTaskBad(title string) {
store := memstore.New() // ← dependency is created inside
_ = store // then some logic...
}
The problem here isn’t that memstore is bad. The problem is that now your business logic “knows” a concrete implementation and effectively says: “I can only work with memstore.” Want to store tasks in a file tomorrow? You’ll have to change the app layer. Want to test? You’ll have to live with the fact that a new store is created every time. Want to reuse it? It becomes awkward.
Manual DI does the opposite: the app layer defines the contract (an interface), and the implementation is created outside—usually in main.
Composition root: one place where everything is wired
A composition root (composition root) is the place where the app is “assembled from parts”: you create concrete implementations, pass them into constructors, get a ready top-level object, and run the scenarios. In small programs, this is almost always main.
It sounds boring, but it genuinely makes life easier: you don’t smear dependency creation across ten files. If something breaks later, you know where to look: in one place.
Here’s the mental model we want for our app:
flowchart TD
main[main: composition root] --> mem[adapters/memstore: concrete implementation]
main --> app[app: use cases/service]
app --> domain[domain: model and rules]
app -->|via interface| mem
Key point: app depends on an interface, and main supplies the implementation.
2. Minimal domain
We won’t overcomplicate the domain right now. What matters is that the domain is “clean”: data structures and rules. Let’s say the rule is: a task title must not be empty. This is familiar error-handling logic: a function either returns a result or an error. In Go this is the standard style, and it fits DI very well because everything becomes explicit and verifiable.
// domain/task.go
package domain
import "errors"
var ErrEmptyTitle = errors.New("empty title")
type Task struct {
ID int
Title string
Done bool
}
And a small constructor:
// domain/new_task.go
package domain
func NewTask(id int, title string) (Task, error) {
if title == "" {
return Task{}, ErrEmptyTitle
}
return Task{ID: id, Title: title, Done: false}, nil
}
So far everything is simple: the domain knows nothing about fmt.Println, files, or CLI. It just enforces the rule.
3. Storage contract in app
Now let’s get to the DI core. We want the “add task” use case to work with any store, as long as it supports the required operations. That’s why the consumer declares the interface (the app layer), not the implementation.
// app/store.go
package app
import (
"context"
"example.com/myapp/domain"
)
type TaskStore interface {
NextID(ctx context.Context) (int, error)
Save(ctx context.Context, t domain.Task) error
}
Notice: app imports domain because it uses domain.Task. That’s fine: use cases know about the domain. But app does not import memstore—and that part is fundamental.
4. Application constructor NewApp
The most recognizable DI technique in Go is constructors like NewX(...). Not because the language requires it, but because it helps create an object immediately in a valid state. We’ll make App—a top-level object of the app layer.
There are two common styles:
- NewApp(store, out, ...) as parameters
- NewApp(Deps{...}) via a deps struct
In learning projects and real services, the second option is often more convenient, because dependencies don’t turn into an “argument sausage” when there are many of them.
Let’s start with Deps.
// app/app.go
package app
import "io"
type Deps struct {
Store TaskStore
Out io.Writer
}
type App struct {
store TaskStore
out io.Writer
}
Now the constructor:
// app/new.go
package app
import (
"errors"
"io"
)
func NewApp(deps Deps) (*App, error) {
if deps.Store == nil {
return nil, errors.New("nil store")
}
if deps.Out == nil {
deps.Out = io.Discard // safe default
}
return &App{store: deps.Store, out: deps.Out}, nil
}
This is DI in the most Go-like form: no magic, no reflection, no containers. Just parameters, checks, and a return value.
5. Use cases in App
Now let’s add the AddTask method. It takes context.Context, because storage operations can potentially be long-running, and because this is the standard contract shape in Go code for operations.
// app/add_task.go
package app
import (
"context"
"fmt"
"example.com/myapp/domain"
)
func (a *App) AddTask(ctx context.Context, title string) (domain.Task, error) {
id, err := a.store.NextID(ctx)
if err != nil {
return domain.Task{}, fmt.Errorf("next id: %w", err)
}
return domain.NewTask(id, title)
}
We’re not saving the task yet—let’s add saving, briefly:
// app/add_task_save.go
package app
import (
"context"
"fmt"
"example.com/myapp/domain"
)
func (a *App) AddAndSave(ctx context.Context, title string) (domain.Task, error) {
t, err := a.AddTask(ctx, title)
if err != nil {
return domain.Task{}, err
}
if err := a.store.Save(ctx, t); err != nil {
return domain.Task{}, fmt.Errorf("save: %w", err)
}
return t, nil
}
Notice the characteristic thing: we don’t create memstore.New() inside. We simply use a.store. That is DI.
6. Adapter memstore
Now let’s write the simplest in-memory store. It will live in adapters/memstore. Important: the adapter imports domain (it needs to store domain.Task), but does not import app. It just “fits” the interface by matching methods.
// adapters/memstore/store.go
package memstore
import (
"context"
"example.com/myapp/domain"
)
type Store struct {
next int
data map[int]domain.Task
}
Constructor:
// adapters/memstore/new.go
package memstore
func New() *Store {
return &Store{next: 1, data: make(map[int]domain.Task)}
}
Methods:
// adapters/memstore/methods.go
package memstore
import "context"
func (s *Store) NextID(ctx context.Context) (int, error) {
id := s.next
s.next++
return id, nil
}
// adapters/memstore/save.go
package memstore
import (
"context"
"example.com/myapp/domain"
)
func (s *Store) Save(ctx context.Context, t domain.Task) error {
s.data[t.ID] = t
return nil
}
Yes, ctx isn’t used here yet. That’s fine. The interface requires it—the implementation accepts it. Later you’ll have implementations where ctx truly matters (for example, file-based or network storage), and you won’t have to rewrite the whole contract.
7. Wiring in main
Now the best part: main is our composition root. Here we create memstore.Store, choose out (let it be stdout), call NewApp, and get a ready application.
// main.go
package main
import (
"context"
"fmt"
"os"
"example.com/myapp/adapters/memstore"
"example.com/myapp/app"
)
func main() {
store := memstore.New()
a, err := app.NewApp(app.Deps{Store: store, Out: os.Stdout})
if err != nil {
fmt.Println("init error:", err) // init error: nil store (if you forgot Store)
return
}
t, err := a.AddAndSave(context.Background(), "learn DI in Go")
if err != nil {
fmt.Println("runtime error:", err)
return
}
fmt.Fprintln(os.Stdout, "added task:", t.Title) // added task: learn DI in Go
}
It looks almost comically simple—and that’s Go’s strength. DI is not a separate technology; it’s a discipline: “create on the outside, pass to the inside.”
8. Useful DI nuances
io.Writer as a dependency
Even if you’re not writing tests yet, io.Writer as a dependency is a great trick for beginners: you stop “nailing” printing to fmt.Println directly inside business logic. Instead, business logic writes where it was told to.
Let’s add a simple method to “tell the user that the task was added.” This isn’t domain; it’s a UX detail, so it’s more logical to keep it at the App level or in an outer layer. But for a learning example, let App be able to print to a.out.
// app/print.go
package app
import (
"fmt"
)
func (a *App) PrintAdded(title string) {
fmt.Fprintln(a.out, "ok:", title) // ok: learn DI in Go
}
And use it in main:
// main.go (fragment)
t, _ := a.AddAndSave(context.Background(), "learn DI in Go")
a.PrintAdded(t.Title) // ok: learn DI in Go
If tomorrow you want to write to a file, main will change, and app will stay the same. That’s “loose coupling” without big words.
Why NewApp sometimes returns (*App, error)
It’s easy for a beginner to freeze: “Why is there an error here, but not there?” The answer is pragmatic: a constructor returns an error if it can realistically detect an invalid configuration and it’s important to stop immediately.
For example, Store == nil is almost a guaranteed future panic (or weird behavior), so it’s better to fail early at the composition root. But Out == nil can be replaced with io.Discard and you can continue.
If a constructor doesn’t validate anything and always produces a correct value, you can return just *App. But as soon as you have “required dependencies,” an error becomes useful because you catch the issue before running any scenarios.
Three micro-rules for manual DI
DI is easy to take to absurd extremes: start passing 20 dependencies into every function and proudly call it architecture. But we want a healthy minimum. Let’s agree on simple rules of behavior—no fanaticism.
- First, dependencies are created by the composition root. That’s usually main, sometimes a separate wiring package, but in a learning project it’s main.
- Second, the app layer must not import adapters: no import adapters/memstore inside app.
- Third, the interface lives where it’s used. If app uses storage, the TaskStore interface belongs in app, and the implementation just matches it.
If you keep these three rules in mind, DI stops being a “framework” and becomes just a coding style: calm and predictable.
9. Common mistakes in manual DI
Mistake #1: “I’ll make a global variable, and that will be DI.”
Globals look like a convenient shortcut, but in practice they’re a hidden dependency: it’s unclear who sets it, when, and whether it can change. The result is code that depends on call order and “initialization magic,” not on explicit parameters.
Mistake #2: Creating a dependency implementation inside a use case.
The most common issue: inside AddTask a memstore.New() or os.OpenFile(...) suddenly appears. This breaks the layering idea: the use case starts knowing infrastructure details. It’s better when the use case knows only the interface, and creation of the implementation stays in main.
Mistake #3: Making the interface “huge just in case.”
Beginners sometimes write an interface with 15 methods because “we might need it.” As a result, implementations are hard to swap, and you make your own life harder. In Go, interfaces are typically small: exactly as many methods as the current consumer needs.
Mistake #4: Not validating dependencies in NewApp and catching a panic later.
If Store is required, it’s better to detect the problem immediately while building the app. Otherwise you’ll get an error in the middle of execution that looks like “for some reason everything is nil,” when in reality it’s a configuration error.
Mistake #5: Passing dependencies “everywhere,” instead of passing one assembled object.
If in main you create store, out, cfg, and you start threading them into every function separately, the code balloons quickly. Often it’s simpler to build App (or Service) once and then work with it as a single top-level object. That’s the point of NewApp(deps...): wire the connections once, then live calmly.
GO TO FULL VERSION