Skip to content
Software Atlas

Dependency Injection

Stop reaching out for databases, clocks and loggers — pass them in. Constructor injection in Go and JavaScript, small interfaces at the consumer, fakes for fast tests, wiring everything in main(), and when a DI framework is (and isn't) worth it.

⏱ ~40 min read14 sections7 interactive widgets · 4 diagrams · quizGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceDependency injection means: a piece of code gets the things it needs handed to it from outside, instead of reaching out and grabbing them itself.

The "things it needs" are its dependencies: a database, an email sender, a logger, even the current time. That's the whole idea. There's no library to install, and in Go most teams never use one. It's a habit, not a framework.

Here's the same picture for code. Press the buttons to swap what's plugged in:

Hard-wired vs plugged in
The same trial service, built two ways. Try switching it to test mode in each.
trial servicegrabs its own
needs a place to save trials🔒 sql.Open("pgx", …)
needs the current time🔒 time.Now()
needs a way to send email🔒 mail.Send(…)
nothing to plug in
The service creates its database, clock and mailer itself. There are no sockets, so there's nothing to swap.
Run it in:
Hard-wired: the code reaches out for everything it needs. Press a unit test to try swapping its dependencies.

The smallest possible example

Both functions below decide whether a shop is open. The first one reaches out for the time; the second one is given the time.

// Reaches out: calls time.Now() itself.
func IsOpen() bool {
    h := time.Now().Hour()
    return h >= 9 && h < 17
}

// Is given what it needs: the caller passes the time in.
func IsOpenAt(now time.Time) bool {
    h := now.Hour()
    return h >= 9 && h < 17
}

Try writing a Unit test for "is the shop closed at 8 pm?". With IsOpen() you'd have to wait until 8 pm to run it. With IsOpenAt you just call IsOpenAt(eightPM), where eightPM is any time you build yourself. That one change — moving the dependency into the parameter list — is dependency injection.

What this page covers

  1. Why "reaching out" hurts (§02), and how to fix it with constructor injection (§03).
  2. The Go and JS tools that make it pleasant: small interfaces (§04), other injection styles (§05).
  3. The big payoff — fast, reliable tests with fakes (§06–§07).
  4. Where all the real pieces get created: main() (§08).
  5. Staff-level topics: DI frameworks (§09), lifetimes (§10), functional DI and React (§11).
  6. The ways it goes wrong (§12), a quiz (§13) and a cheat sheet (§14).
Foundations§02

The problem: code that reaches out

Here is a perfectly normal-looking HTTP handler. When a user signs up for a free trial, it saves "trial ends in 14 days" to the database, emails them a welcome code, and writes a log line. It works. Ship it?

handler.go
func StartTrial(w http.ResponseWriter, r *http.Request) {
    db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
    if err != nil {
        http.Error(w, "db error", http.StatusInternalServerError)
        return
    }
    defer db.Close()

    userID := r.FormValue("user")
    endsAt := time.Now().Add(14 * 24 * time.Hour)
    code := fmt.Sprintf("WELCOME-%04d", rand.IntN(10000))

    _, err = db.Exec("INSERT INTO trials(user_id, ends_at, code) VALUES ($1, $2, $3)",
        userID, endsAt, code)
    if err != nil {
        http.Error(w, "db error", http.StatusInternalServerError)
        return
    }
    if err := mail.Send(r.FormValue("email"), "Your trial", "Use code "+code); err != nil {
        log.Printf("email failed: %v", err)
    }
    log.Printf("trial started for %s", userID)
    w.WriteHeader(http.StatusCreated)
}

The function's signature says it needs a request and a response writer. But its body needs much more: a database, a config value, the clock, a random number generator, an email server and a logger. None of those show up in the parameter list. They're hidden dependencies.

Find all six yourself. Click the lines you think reach out to something outside the function:

Hidden dependency hunt
Click every line that reaches outside the function for something it needs.
Found0 / 6
? ? ?
? ? ?
? ? ?
? ? ?
? ? ?
? ? ?
Start with the obvious one: which line talks to a database?

Why hidden dependencies hurt

Now try to write a test that checks one simple fact: "the trial ends exactly 14 days from now".

  1. It needs a real Postgres. The first query connects to whatever DATABASE_URL points at. No database, no test.
  2. It sends a real email to whatever address you use — every time the test runs.
  3. You can't know the expected answer. time.Now() is different on every run, so what exact date do you compare against?
  4. The code is random. You can't assert "the email contains code WELCOME-0042".
  5. You can't swap anything. Want to try a different email provider, or run against SQLite on your laptop? You have to edit this function.

Each of these is the same root cause: the function decides for itself where its dependencies come from. That's tight coupling — the handler is welded to Postgres, to one mail package and to the real clock.

The fix is not clever. It's the lamp plug from §01: let someone else hand these things in. The next section does exactly that, one dependency at a time.

Foundations§03

Constructor injection: pass it in

In one sentenceGive the code a constructor that takes every dependency as an argument, store them in fields, and use the fields instead of reaching out.

A constructor is the code that builds a ready-to-use object — NewThing(…) in Go, new Thing(…) in JS. Constructor injection means its parameters are the object's dependencies. Whoever calls the constructor decides which database, which mailer and which clock the object gets.

Before the code, three Go building blocks it uses. A Struct groups named fields into one type (like a JS object with a fixed shape). A Method is a function attached to a type. An Interface is a list of method signatures with no code: any type that has those methods can be used where the interface is expected. That last one is what will let a test pass in a stand-in.

Step through the refactor. Watch the "Dependencies" panel: each red (hidden) dependency turns green (passed in).

Refactor: hard-wired → injected
Seven small steps (use ← → keys too). Each one moves a dependency out of the body and into the struct.
Start: everything hard-wired
1func StartTrial(w http.ResponseWriter, r *http.Request) {
2 db, _ := sql.Open("pgx", os.Getenv("DATABASE_URL"))
3 endsAt := time.Now().Add(14 * 24 * time.Hour)
4 db.Exec("INSERT INTO trials …", r.FormValue("user"), endsAt)
5 mail.Send(r.FormValue("email"), "Your trial", "Enjoy!")
6 log.Printf("trial started")
7}
Dependencies0 / 5 passed in
⚠ database — hidden
⚠ config — hidden
⚠ clock — hidden
⚠ mailer — hidden
⚠ logger — hidden
The handler from §02, trimmed down (errors and the random code left out to keep it short). Five hidden dependencies, all red. We'll fix them one at a time, without changing what the code does.
1 / 7

The result, in both languages

trial/service.go
package trial

// The two things this service needs, described by what it calls on them.
// (§04 explains why these interfaces live HERE, next to the code that uses them.)
type Store interface {
    SaveTrial(ctx context.Context, userID string, endsAt time.Time) error
}

type Mailer interface {
    Send(ctx context.Context, to, subject, body string) error
}

type Service struct {
    store Store              // lower-case = private: only this package can touch them
    mail  Mailer
    now   func() time.Time   // the clock, as a function
    log   *slog.Logger
}

// The constructor: every dependency is a parameter. ← this is the whole trick
func NewService(store Store, mail Mailer, now func() time.Time, log *slog.Logger) *Service {
    return &Service{store: store, mail: mail, now: now, log: log}
}

const trialLength = 14 * 24 * time.Hour

func (s *Service) Start(ctx context.Context, userID, email string) (time.Time, error) {
    endsAt := s.now().Add(trialLength)            // not time.Now()
    if err := s.store.SaveTrial(ctx, userID, endsAt); err != nil {
        return time.Time{}, fmt.Errorf("save trial: %w", err)
    }
    if err := s.mail.Send(ctx, email, "Your trial has started", "Enjoy!"); err != nil {
        s.log.Warn("welcome email failed", "user", userID, "err", err) // like §02: log it, carry on
    }
    s.log.Info("trial started", "user", userID, "ends", endsAt)
    return endsAt, nil
}

What changed — and what didn't

The logic is the same: compute the end date, save it, email (and only log if that fails, just like §02), log. What changed is who decides:

BeforeAfter
Which database?The handler (calls sql.Open)Whoever calls NewService
What time is it?time.Now() insideThe now function passed in
Which logger?The global log packageThe *slog.Logger passed in
Can a test swap them?NoYes — pass in stand-ins

You've just built a Seam: a place where one implementation can be swapped for another without editing the code around it. The service's job got smaller, too. It no longer knows about connection strings or environment variables. (We dropped the random welcome code for now; §07 brings it back properly.)

Who calls NewService, then?

Somebody still has to open the real database and pass it in. That "somebody" is pushed outwards — ideally all the way up to main(), the one place that knows about real Postgres and real email servers. §08 builds that main() and its whole dependency graph. First, let's look more closely at those two little interfaces.

Practitioner§04

Interfaces at the consumer

In one sentenceDescribe a dependency by the few methods you actually call, and declare that description in your own package — next to the code that uses it.

In §03, trial.Service asked for a Store and a Mailer. Both are interfaces: lists of methods, with no code. Any type that has those methods can be passed in. That's what lets a test pass a stand-in instead of the real Postgres store.

Ask for what you use, not what exists

Suppose the team already has a big Postgres store type with twenty methods — GetUser, DeleteTrial, ListInvoices, and so on. The trial service calls exactly one of them: SaveTrial. So its interface lists exactly one:

// In package trial — the CONSUMER.
type Store interface {
    SaveTrial(ctx context.Context, userID string, endsAt time.Time) error
}

Small interfaces pay off three ways:

  • Tests stay tiny. A fake needs one method, not twenty.
  • The signature documents the real need. Reading Store tells you the service only ever saves trials — it never deletes users.
  • Any implementation fits. Postgres today, an in-memory map in tests, a different database tomorrow.

Go makes this natural: no implements

In Go, a type satisfies an interface just by having the methods (Implicit interface). The Postgres store never mentions trial.Store; it doesn't even need to know the trial package exists:

postgres/store.go
package postgres

type Store struct{ db *sql.DB }

func NewStore(db *sql.DB) *Store { return &Store{db: db} }

// No "implements trial.Store" anywhere. Having this method is enough.
func (s *Store) SaveTrial(ctx context.Context, userID string, endsAt time.Time) error {
    _, err := s.db.ExecContext(ctx,
        "INSERT INTO trials(user_id, ends_at) VALUES ($1, $2)", userID, endsAt)
    return err
}

// …plus GetUser, DeleteTrial, ListInvoices — the trial package doesn't care.

So the arrows point like this — the low-level database code doesn't import the business code, and the business code doesn't import the database code. Only main sees both:

The interface lives with the code that uses it
package mainsvc := trial.NewService(pg, …)package trialtype Store interface { SaveTrial(…) error}package postgrestype Store struct{ db }func (s *Store) SaveTrial(…)// + 19 other methodstrial_test.go: memStoreimportsimportssatisfies (no import)
Solid arrows are imports. Dashed arrows mean “has the right methods”, which needs no import at all. Only main imports both trial and postgres, and plugs one into the other.

postgres.Store is called an adapter: the real code behind an interface that talks to the outside world.

"Accept interfaces, return structs"

Look at the constructor again: NewService(store Store, …) *Service. It accepts interfaces (so callers can pass anything that fits) and returns a concrete struct pointer (so callers get the full type, not a narrow view of it). That's a well-known Go proverb. It keeps the interface owned by the consumer, where it belongs. The full idiom, with its exceptions, is in Go Idioms as Patterns.

✕ Interface owned by the producer
// package postgres
type TrialStore interface {   // 20 methods, defined
    SaveTrial(…) error        // next to the ONE
    DeleteTrial(…) error      // implementation…
    // …18 more
}
func NewStore(db *sql.DB) TrialStore // …and returned
                                     // instead of *Store
✓ Interface owned by the consumer
// package trial
type Store interface {        // just what trial uses
    SaveTrial(…) error
}

// package postgres
func NewStore(db *sql.DB) *Store   // return the struct

JavaScript: interfaces by convention

JavaScript has no interfaces at all. It uses Duck typing: TrialService calls this.#store.saveTrial(…), and any object with a saveTrial method works — a class instance, or a plain object literal written in a test:

const store = { async saveTrial(id, endsAt) { /* keep it in a Map */ } };
new TrialService({ store, /* … */ });

If you use TypeScript, its interfaces work like Go's: structural (shape-based), so you can declare a small interface TrialStore { saveTrial(id: string, endsAt: Date): Promise<void> } next to the service, and any matching object is accepted without an implements clause.

Practitioner§05

Other ways to inject: parameters, setters, functions

Constructor injection is the default, but there are three other ways to hand something in. They differ in when the dependency arrives and how long it's kept.

StyleHow it arrivesKept forUse it for
ConstructorNewService(store, …)The object's whole lifeAlmost everything: databases, clients, loggers
Parameter (method)svc.Export(ctx, w)One callThings that change per call: the request, where to write output
Settersvc.SetMailer(m) after creationUntil replacedRarely — see below
FunctionNewService(…, time.Now)Like constructorSingle-behavior dependencies: clocks, ID generators

Parameter injection: when it's different every call

If a dependency changes on every call, it belongs in that call's parameters, not in a field. Writing a report to "somewhere" is the classic example: an HTTP response today, a file tomorrow, a buffer in tests.

// w is injected per call. io.Writer is a one-method interface: Write([]byte) (int, error).
func (s *Service) ExportCSV(ctx context.Context, w io.Writer) error {
    _, err := fmt.Fprintln(w, "user_id,ends_at")
    // … write rows …
    return err
}

// production: svc.ExportCSV(ctx, httpResponseWriter)
// test:       var buf bytes.Buffer; svc.ExportCSV(ctx, &buf); check buf.String()

You've already seen one per-call parameter everywhere: Go's ctx context.Context. It carries this request's deadline and cancel signal, so it's passed per call — never stored in a struct field.

Setter injection: why it's weaker

A setter creates the object first and plugs dependencies in later:

✕ Setter injection
svc := &Service{}
svc.SetStore(store)
// forgot SetMailer, SetClock, SetLogger…
svc.Start(ctx, "u1", "a@b.c")
// panic: nil pointer dereference
// (unset fields are nil; the first one
//  Start touches — s.now() — panics)
✓ Constructor injection
svc := NewService(store, mailer, time.Now, logger)
// Forget an argument → compile error:
//   not enough arguments in call to NewService

With a setter, there's a window where the object exists but isn't ready, and nothing forces anyone to finish setting it up. Someone could also call the setter again halfway through the program's life — while other goroutines are using the old value (a Data race). Constructor injection makes "half-built" impossible: you can't get a *Service without handing over everything it needs. Setters only make sense for a genuinely optional extra that's set once at startup.

Function injection: when one behavior is all you need

If a dependency has exactly one behavior, pass a function instead of declaring an interface. Go and JS both have first-class functions, so a function is a perfectly good dependency:

// A service that needs only a clock and an ID generator.
type Service struct {
    now   func() time.Time // the clock
    newID func() string    // an ID generator
}

func NewService(now func() time.Time, newID func() string) *Service {
    return &Service{now: now, newID: newID}
}

prod := NewService(time.Now, uuid.NewString)                 // production
test := NewService(func() time.Time { return fixed },        // test: frozen time…
                   func() string { return "id-1" })          // …and predictable IDs

Optional dependencies and defaults

Sometimes a dependency has an obvious production default — the real clock, console as the logger. JS makes this easy with default parameter values; Go uses a small options struct (or just passes the real thing in main):

type Options struct {
    Now func() time.Time // nil → time.Now
    Log *slog.Logger     // nil → slog.Default()
}

func NewService(store Store, mail Mailer, opts Options) *Service {
    if opts.Now == nil {
        opts.Now = time.Now
    }
    if opts.Log == nil {
        opts.Log = slog.Default()
    }
    return &Service{store: store, mail: mail, now: opts.Now, log: opts.Log}
}

Keep required dependencies required. Defaulting a database to "connect to localhost" quietly brings the hard-wired lamp back.

Practitioner§06

Testing seams: fakes, stubs and mocks

In one sentenceEvery injected dependency is a seam. In a test, you plug a stand-in into each seam and check what your code did — with no database, no network and no waiting.

A stand-in for a real dependency is called a Test double (from "stunt double"). There are three kinds you'll meet constantly. The names get mixed up a lot, so here's what each one does:

1

Fake

A simpler, working version. A store backed by a map instead of Postgres. You check the result afterwards ("is u1 saved?").

2

Stub

A canned answer. "SaveTrial always fails with db down." Used to push the code down one path, usually an error path.

3

Mock

A call recorder. It remembers which methods were called, how often and with what. The test checks the calls.

First, feel the difference

Below is the test for trial.Service.Start from §03. Flip each dependency between the real thing and a test double, then run the test 10 times. Watch the time, the failures and the side effects.

Test bench: real vs fake
Choose what to plug into each seam, then run TestStartTrial 10 times.
store
mailer
clock
logger
svc := NewService(postgres.NewStore(db), smtp.New(addr), time.Now, jsonLogger)
··········
passed
—
total time
—
network calls
—
real emails sent
—
Everything starts real, like the hard-wired handler. Press go test, then flip dependencies to their fakes one at a time and run again.

With every real dependency, the test is slow, needs a database and an email server to be running, sends real emails, and fails anyway, because the real clock makes the expected date impossible to know in advance. With doubles, it takes about a millisecond and passes every single time. Most of this section is just how to write those doubles.

The test in Go

trial/service_test.go
package trial

// A fake: a real, working store that keeps everything in a map.
type memStore struct{ saved map[string]time.Time }

func (m *memStore) SaveTrial(_ context.Context, userID string, endsAt time.Time) error {
    m.saved[userID] = endsAt
    return nil
}

// Records what would have been sent, instead of sending it.
type fakeMailer struct{ sentTo []string }

func (f *fakeMailer) Send(_ context.Context, to, _, _ string) error {
    f.sentTo = append(f.sentTo, to)
    return nil
}

func TestStartTrial(t *testing.T) {
    store := &memStore{saved: map[string]time.Time{}}
    mail := &fakeMailer{}
    fixed := time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)
    clock := func() time.Time { return fixed }          // ← time stands still in this test

    svc := NewService(store, mail, clock, slog.New(slog.DiscardHandler))

    endsAt, err := svc.Start(context.Background(), "u1", "ana@example.com")
    if err != nil {
        t.Fatalf("Start: %v", err)
    }
    want := time.Date(2026, 1, 15, 9, 0, 0, 0, time.UTC) // exactly 14 days later
    if !endsAt.Equal(want) {
        t.Errorf("endsAt = %v, want %v", endsAt, want)
    }
    if got := store.saved["u1"]; !got.Equal(want) {
        t.Errorf("saved %v, want %v", got, want)
    }
    if len(mail.sentTo) != 1 || mail.sentTo[0] != "ana@example.com" {
        t.Errorf("emails sent to %v, want [ana@example.com]", mail.sentTo)
    }
}

And a stub for the failure path. Notice what it checks: when the database is down, the user must not get a "your trial started" email.

type failingStore struct{}

func (failingStore) SaveTrial(context.Context, string, time.Time) error {
    return context.DeadlineExceeded // pretend the database timed out
}

func TestStartTrialStoreDown(t *testing.T) {
    mail := &fakeMailer{}
    svc := NewService(failingStore{}, mail, time.Now, slog.New(slog.DiscardHandler))
    if _, err := svc.Start(context.Background(), "u1", "ana@example.com"); err == nil {
        t.Fatal("want error when the store fails")
    }
    if len(mail.sentTo) != 0 {
        t.Errorf("sent %d emails, want 0", len(mail.sentTo))
    }
}

The same tests in JavaScript (node:test)

Node has a built-in test runner, node:test — no install needed. Run with node --test.

trial-service.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { TrialService } from "./trial-service.js";

test("start() saves a 14-day trial and sends one email", async () => {
  const saved = new Map();
  const store = { async saveTrial(id, endsAt) { saved.set(id, endsAt); } }; // fake
  const sent = [];
  const mailer = { async send(to) { sent.push(to); } };                     // records, never sends
  const fixed = new Date("2026-01-01T09:00:00Z");

  const svc = new TrialService({ store, mailer, now: () => fixed, log: { info() {} } });
  const endsAt = await svc.start("u1", "ana@example.com");

  assert.equal(endsAt.toISOString(), "2026-01-15T09:00:00.000Z");
  assert.deepEqual(saved.get("u1"), endsAt);
  assert.deepEqual(sent, ["ana@example.com"]);
});

test("no email when the store fails", async (t) => {
  const store = { saveTrial: async () => { throw new Error("db down"); } };  // stub
  const mailer = { send: t.mock.fn(async () => {}) };                        // mock: records calls
  const svc = new TrialService({ store, mailer, now: () => new Date(), log: { info() {} } });

  await assert.rejects(svc.start("u1", "ana@example.com"), /db down/);
  assert.equal(mailer.send.mock.callCount(), 0);
});

Prefer fakes; use mocks sparingly

Mocks are tempting because a library can generate them. But a mock-heavy test checks how the code works ("called SaveTrial once with these exact arguments, then Send"), not what it achieved. Rename a method or batch two calls into one, and dozens of tests break even though the behavior is unchanged.

Practitioner§07

Injecting time, randomness, HTTP and files

In one sentenceAnything your code reads from the outside world is a dependency — even if it's one built-in call like time.Now(), Math.random() or fetch().

These five are the most common causes of flaky tests — tests that pass on your laptop and fail on CI at 23:59. Each gets the same fix: pass it in.

1. The clock

You've seen the simplest version: a now func() time.Time field. Production passes time.Now; tests pass a frozen moment. When a test needs time to move — "the trial is expired 15 days later" — use a tiny fake clock you can push forward:

type FakeClock struct{ t time.Time }

func (c *FakeClock) Now() time.Time          { return c.t }
func (c *FakeClock) Advance(d time.Duration) { c.t = c.t.Add(d) }

// In a test:
clock := &FakeClock{t: time.Date(2026, 1, 1, 9, 0, 0, 0, time.UTC)}
svc := NewService(store, mail, clock.Now, logger) // ← pass the method as the clock
svc.Start(ctx, "u1", "ana@example.com")
clock.Advance(15 * 24 * time.Hour)
// now check svc.IsExpired("u1") == true

2. Randomness

Random welcome codes (from §02) are impossible to assert on — unless the source of randomness is injected:

type Codes struct{ randN func(n int) int }

func NewCodes(randN func(int) int) *Codes { return &Codes{randN: randN} }

func (c *Codes) Welcome() string {
    return fmt.Sprintf("WELCOME-%04d", c.randN(10000))
}

NewCodes(rand.IntN)                          // production (math/rand/v2)
NewCodes(func(int) int { return 42 })        // test → always "WELCOME-0042"
NewCodes(rand.New(rand.NewPCG(1, 2)).IntN)   // test → random-looking, but the same every run

The third Go line uses a seeded generator: same seed (1, 2), same sequence every run. That's handy when you want realistic variety but repeatable failures.

3. HTTP calls to other services

For a client that calls an external API, inject where it calls (the base URL) and how (the HTTP client). Then a test can point it at a fake server.

weather/weather_test.go
func TestTempC(t *testing.T) {
    // A real HTTP server on 127.0.0.1, started just for this test.
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Query().Get("city") != "Hanoi" {
            http.Error(w, "unknown city", http.StatusNotFound)
            return
        }
        fmt.Fprint(w, `{"temp_c": 31.5}`)
    }))
    defer srv.Close()

    c := weather.NewClient(srv.URL, srv.Client()) // ← point the client at the fake server
    got, err := c.TempC(context.Background(), "Hanoi")
    if err != nil {
        t.Fatal(err)
    }
    if got != 31.5 {
        t.Errorf("TempC = %v, want 31.5", got)
    }
}

4. Files

In Go, accept an fs.FS (a read-only filesystem interface from the standard library) instead of calling os.ReadFile with a hard-coded path. Production passes os.DirFS("assets"); tests pass an in-memory fstest.MapFS:

func LoadGreeting(fsys fs.FS, lang string) (string, error) {
    b, err := fs.ReadFile(fsys, "greetings/"+lang+".txt")
    return string(b), err
}

// test:
fsys := fstest.MapFS{"greetings/vi.txt": {Data: []byte("Xin chào")}}
got, _ := LoadGreeting(fsys, "vi") // "Xin chào" — no files on disk

5. Configuration

os.Getenv("DATABASE_URL") buried in a function is a hidden dependency on the environment. Read environment variables once, in main, into a plain struct, and pass the values down:

✕ Reading config deep inside
func NewMailer() *Mailer {
    return &Mailer{
        addr: os.Getenv("SMTP_ADDR"),
    }
}
✓ Reading config once, passing values
func NewMailer(addr string) *Mailer {
    return &Mailer{addr: addr}
}

// in main:
cfg, err := loadConfig() // reads every env var once; errors if one is missing
mailer := NewMailer(cfg.SMTPAddr)

Now the mailer can be tested with any address. And if loadConfig checks that required values are present (and returns an error otherwise — §08's main does this), a missing setting fails once, loudly, at startup — not on the first email three hours later.

Practitioner§08

The composition root: wiring it all in main()

In one sentenceAll the "who gets which database" decisions you pushed outwards end up in one place: main(). It builds every real dependency, plugs them together, and later shuts them down — in reverse order.

That place has a name: the Composition root. It's the only code that knows about Postgres, SMTP, environment variables and the real clock all at once. Everything below it just receives what it needs.

The dependency graph

Draw an arrow from each piece to the things it needs, and you get a graph. Something can only be built after everything it points at exists. Click the nodes to build them yourself (you'll get an error if you skip ahead), or press Build all. Then shut down with Ctrl+C and watch the order flip.

Wire up main()
Arrows mean “is passed into”. Green dashed = ready to build. Click to build, or let it run.
configlogger*sql.DBmailerstoretrial.Servicerouterhttp.Server
run() — in the order you built
nothing yet — start with a node that needs nothing
Shutdown order
  1. appears once everything is built
Only config and logger need nothing, so they're ready (green). (The clock, time.Now, is just a function — nothing to build.) Try clicking trial.Service first to see what happens.

The code

cmd/server/main.go
func main() {
    if err := run(); err != nil {
        slog.Error("fatal", "err", err)
        os.Exit(1)
    }
}

func run() error {
    cfg, err := loadConfig()                              // 1. read env vars once…
    if err != nil {                                       //    …and fail fast if one is missing
        return err
    }
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

    db, err := sql.Open("pgx", cfg.DatabaseURL)           // 2. outside-world resources (Postgres…
    if err != nil {
        return err
    }
    defer db.Close()                                      // ← runs LAST

    mailer := smtpmail.New(cfg.SMTPAddr)                  //    …and an SMTP email server)
    defer mailer.Close()                                  // ← runs second-to-last

    store := postgres.NewStore(db)                        // 3. adapters
    trials := trial.NewService(store, mailer, time.Now, logger) // 4. business logic
    srv := &http.Server{                                  // 5. the edge: HTTP
        Addr:    cfg.Addr,
        Handler: api.NewRouter(trials, logger),
    }

    // Wait for Ctrl+C / SIGTERM, then shut down gracefully.
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    errCh := make(chan error, 1)
    go func() { errCh <- srv.ListenAndServe() }()

    select {
    case err := <-errCh:                                  // server failed to start
        return err
    case <-ctx.Done():                                    // shutdown signal
    }

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    return srv.Shutdown(shutdownCtx) // 6. stop HTTP first; then the defers close mailer, then db
}

Why the order matters

Construction goes from things with no dependencies (config, logger) up to the thing that depends on everything (the HTTP server). Go enforces this: you can't pass store to NewService before the line that creates store — that's a compile error, undefined: store.

Shutdown must go the other way. Picture closing the database first: requests that are still in flight would suddenly fail with "sql: database is closed". So:

  1. Stop the HTTP server → no new requests; wait for current ones to finish.
  2. Close the mailer → nobody can be sending email now.
  3. Close the database → nobody can be querying now.

Rule: build in dependency order, tear down in reverse. With defer placed right after each successful creation, Go does the reversing for you.

The same thing in Node

JavaScript has no defer, so you keep the cleanup list yourself — and run it backwards:

main.js
import pg from "pg";
import { createServer } from "node:http";

const cfg = loadConfig();
const cleanups = [];                                       // our hand-made "defer stack"

const pool = new pg.Pool({ connectionString: cfg.databaseUrl });
cleanups.push(() => pool.end());

const mailer = createSmtpMailer(cfg.smtpAddr);
cleanups.push(() => mailer.close());

const store = createPgTrialStore(pool);
const trials = new TrialService({ store, mailer, now: () => new Date(), log: console });
const server = createServer(createRouter({ trials, log: console }));
server.listen(cfg.port);

async function shutdown() {
  await new Promise((resolve) => server.close(resolve));   // 1. stop HTTP, let requests finish
  for (const fn of cleanups.reverse()) await fn();         // 2. mailer, then pool — reverse order
  process.exit(0);
}
process.once("SIGTERM", shutdown);                         // the platform asks us to stop
process.once("SIGINT", shutdown);                          // Ctrl+C in a terminal
Staff-level§09

DI containers: Wire, Fx, NestJS

In one sentenceA DI container automates §08: you list how to build each type, ask for the one at the top, and the library works out the order and calls the constructors for you.

Containers are the reason many people think DI is complicated. It isn't — the containers are. They're a tool for one specific problem: a composition root so large that writing it by hand becomes the bottleneck. They're an example of Inversion of control: you stop calling constructors yourself; the library calls them.

First, what every container does: match types

A container looks at each constructor (it calls them providers) and asks two questions: what type does it return? and what types do its parameters need? Then it connects returns to parameters. There's one catch that surprises everyone: containers match exact types. NewStore returns *postgres.Store (a struct — "return structs", §04), but trial.NewService asks for trial.Store (an interface). To the container those are two different types, so you must tell it which struct fills which interface. Try it:

How a container matches types
Providers on the left return types. NewService's parameters on the right need types. Run wire and see which slots get filled.
Providers → return type
NewDB(cfg)*sql.DB
NewStore(db)*PGStore
NewMailer(cfg)*SMTPMailer
NewClock()func() time.Time
NewLogger()*slog.Logger
trial.NewService needs
trial.Store
trial.Mailer
func() time.Time
*slog.Logger
Notice: NewStore returns *PGStore, a struct, but the slot wants trial.Store, an interface. Will the container connect them on its own? Press run wire.

Google Wire — generates the wiring code (compile time)

You write the list of providers and the shape of the function you want. The wire command reads them and generates ordinary Go code — the same lines you'd have written by hand (Code generation). wire.Bind is how you say "use this struct wherever that interface is needed". (To keep the example short, the two adapters from §04 are called PGStore and SMTPMailer here.)

//go:build wireinject

package main

// You write only the "shape"; the wire tool generates the body.
func InitializeTrials(cfg Config) (*trial.Service, func(), error) {
    wire.Build(
        NewLogger, NewDB, NewStore, NewMailer, NewClock, trial.NewService,
        wire.Bind(new(trial.Store), new(*PGStore)),      // *PGStore fills trial.Store
        wire.Bind(new(trial.Mailer), new(*SMTPMailer)),  // *SMTPMailer fills trial.Mailer
    )
    return nil, nil, nil // never runs — replaced by the generated version
}

Forget a provider (or a wire.Bind) and wire refuses to generate:

wire: wire.go:11:1: inject InitializeTrials: no provider found for example.com/app/trial.Store
	needed by *example.com/app/trial.Service in provider "NewService" (trial/service.go:26:6)

You find out before your program even compiles. NewDB returns a third value, a cleanup function; Wire collects these into the one cleanup it hands back, and calls them in reverse order.

Uber Fx (built on dig) — builds the graph at runtime

Fx uses Reflection: when the program starts, it inspects each constructor's parameter and return types and calls them in the right order. It also manages start/stop lifecycle hooks, so cleanup is registered next to construction.

func NewDB(cfg Config, lc fx.Lifecycle) (*sql.DB, error) {
    db, err := sql.Open("pgx", cfg.DatabaseURL)
    if err != nil {
        return nil, err
    }
    lc.Append(fx.Hook{OnStop: func(context.Context) error { return db.Close() }})
    return db, nil
}

func main() {
    fx.New(
        fx.Provide(
            LoadConfig, NewLogger, NewDB, NewClock, trial.NewService,
            fx.Annotate(NewStore, fx.As(new(trial.Store))),   // provide *PGStore AS a trial.Store
            fx.Annotate(NewMailer, fx.As(new(trial.Mailer))), // same idea as wire.Bind
        ),
        fx.Invoke(func(s *trial.Service) { /* start using it */ }), // "I need a *trial.Service"
    ).Run() // builds the graph, runs OnStart hooks, waits for Ctrl+C, runs OnStop hooks in reverse
}

Forget NewDB here and the program compiles fine, then fails the moment it starts: missing type: *sql.DB (did you mean to Provide it?). Forget the fx.As and you get missing type: trial.Store (did you mean to use *main.PGStore?). Earlier than a crash on the first request — but later than a compile error.

JavaScript/TypeScript — NestJS and InversifyJS

In the Node world, containers usually come with a framework. NestJS marks classes with decorators and reads the constructor's parameter types:

trial.module.ts (NestJS)
export const TRIAL_STORE = Symbol("TRIAL_STORE");          // a token (see below)
export interface TrialStore { saveTrial(id: string, endsAt: Date): Promise<void>; }

@Injectable()
export class TrialService {
  constructor(
    @Inject(TRIAL_STORE) private readonly store: TrialStore, // interface → needs an explicit token
    private readonly mailer: Mailer,                         // class → the class itself is the token
  ) {}
}

@Module({
  providers: [TrialService, Mailer, { provide: TRIAL_STORE, useClass: PgTrialStore }],
})
export class TrialModule {}

Why the TRIAL_STORE symbol? TypeScript interfaces disappear when the code is compiled to JavaScript, so at runtime there's nothing called TrialStore for the container to look up. Classes survive, so a class can be its own key; for an interface you need an explicit injection token. InversifyJS works the same way (@injectable(), @inject(TYPES.TrialStore)).

When do you find out something is missing?

That's the most important difference between the options:

Forgot to provide the database: when does it blow up?
edit codewire gengo buildstart1st request
by hand
edit
—
✕
Wire
edit
✕
Fx / Nest
edit
—
✓
✕
locator
edit
—
✓
✓
✕ panic
The further right the ✕, the later (and more expensively) you find out. Hand wiring and Wire fail before the program exists. Runtime containers fail at startup — usually caught by CI or a deploy health check. A service locator or ctx.Value lookup fails only when that code path first runs, possibly hours later in production.

Honest trade-offs

By handWireFx / digNestJS / Inversify
Missing dependency found atcompilegenerate (before compile)startupstartup
Interface ↔ structAutomatic (Go checks it)wire.Bindfx.AsInjection tokens
Can you read the wiring?Yes, it's just codeYes, generated codeNo — it's computed at runtimePartly (module lists)
"Go to definition" works?YesYesNot through the containerPartly
Extra concepts to learnNoneProviders, sets, build tagsProvide, Invoke, lifecycle, groupsDecorators, modules, tokens, scopes
Maintained?— (it's your code)No (since 2025)YesYes
Pays off whenAlmost alwaysExisting projects already using itMany teams share modules in one big appYou already use the framework
Staff-level§10

Lifetimes and request-scoped dependencies

In one sentenceEvery dependency has a lifetime: one for the whole app, one per request, or a new one every time. Getting it wrong causes some of the nastiest bugs DI can produce.

A Lifetime answers: how long does one instance live, and who shares it? Containers make you pick one of three names. When you wire by hand, you pick it by where you call the constructor.

LifetimeOne instance per…ExamplesBy hand in Go
Singletonwhole programDB pool, HTTP client, logger, trial.ServiceBuilt once in main
Scoped (per request)requestthe logged-in user, a request-ID logger, a DB Database transactionBuilt inside the handler or Middleware
Transientevery usea validator, a bufferConstructor called each time you need one

Send a few requests and watch which instances are shared:

Lifetime lab
Each request runs a handler and a service. Same color + number = the same instance, shared.
No requests yet.
singleton
0 dbPool
one for the whole app
per request
0 reqLog
one per request
transient
0 validator
new on every use
Send a few requests. Watch which chips keep the same number across requests and which get a new one.

Singletons must be safe to share

A singleton is used by every request at once — in Go, from many goroutines at the same time. So it must be safe for concurrent use. *sql.DB, *http.Client and *slog.Logger are documented as safe. Your own services are safe if they don't change their fields after construction — another reason to inject in the constructor and never through setters (§05).

The captive-dependency bug

The classic lifetime mistake: a long-lived object holds onto a short-lived one. Here, a singleton service captures the first request's user and keeps it forever:

✕ A singleton holding request data
type Audit struct {
    user string // ← set once…
}

// in main — runs ONCE:
audit := &Audit{user: currentUser(firstReq)}

// every later request logs as the FIRST user
audit.Record("deleted invoice 42")
✓ Request data passed per call
type Audit struct {
    log *slog.Logger // only app-lifetime deps here
}

func (a *Audit) Record(ctx context.Context, user, action string) {
    a.log.InfoContext(ctx, action, "user", user)
}

Here's the same bug on a timeline. A request's data should die with its request — but the singleton keeps it alive:

The captive dependency, on a timeline
service
singleton — lives for the whole program
req 1
alice
req 2
bob
req 3
chen
req 4
dana
captured
log(alice)
still log(alice) — used for bob, chen, dana ✕
Each request's logger should live only as long as its request (short bars). But the singleton service, built during request 1, grabbed alice's logger — so that logger (striped) outlives its request and is used for bob, chen and dana too.

Rule: a dependency may only depend on things that live at least as long as it does. Singletons can use singletons. Per-request objects can use per-request objects and singletons. Never the other way round. (Some containers help: ASP.NET Core can flag this at startup, and NestJS quietly makes the long-lived consumer per-request too. With hand wiring, it's on you and code review.)

Request-scoped values in Go: parameters or context?

Go has no built-in "request scope" container. You have two honest options:

  1. Explicit parameters — Record(ctx, user, action). Most readable; the compiler checks it.
  2. context.Context values — middleware stores request-scoped data in the context, and code deep down reads it. Good for cross-cutting data that nearly every log line or outgoing call needs: request ID, trace ID, the authenticated user.
type ctxKey struct{} // an unexported key type, so no other package can collide with it

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, ctxKey{}, id)
}

func RequestID(ctx context.Context) string {
    id, _ := ctx.Value(ctxKey{}).(string) // "", if not set
    return id
}

Per-request objects by hand

When you do need a real per-request object — a logger tagged with this request's ID, or a database transaction — build it at the start of the request, from the singletons:

func (h *Handler) StartTrial(w http.ResponseWriter, r *http.Request) {
    log := h.log.With("request_id", RequestID(r.Context())) // scoped: built per request
    tx, err := h.db.BeginTx(r.Context(), nil)               // scoped: one transaction per request
    if err != nil {
        http.Error(w, "db error", http.StatusInternalServerError)
        return
    }
    defer tx.Rollback() // no-op if Commit already succeeded

    svc := trial.NewService(postgres.NewStore(tx), h.mailer, h.now, log) // cheap: just a struct
    // … svc.Start(…), then tx.Commit()
}

So is trial.Service a singleton or per-request? Either: lifetime is decided by where you construct it, not by the type. When it needs only app-wide pieces, build it once in main; when it needs per-request pieces (a transaction, a tagged logger), build it per request like this.

Building a small struct per request is cheap in Go — a few nanoseconds of allocation, nothing like opening a connection. Expensive things (the pool, HTTP clients) stay singletons. Note that this version needs postgres.NewStore to accept a transaction as well as a pool — usually via a tiny interface with just the ExecContext method both *sql.DB and *sql.Tx have.

Staff-level§11

Functional DI, React context and package cycles

DI isn't tied to classes or structs. Anything that lets the caller choose a dependency counts. Three places it shows up in disguise:

1. Closures: a constructor without a class

A Closure remembers the variables around it, so a function that takes dependencies and returns a function is a constructor in everything but name. That outer function is a Higher-order function.

// Takes app-lifetime dependencies once; returns the per-request handler.
func handleStartTrial(trials *trial.Service, log *slog.Logger) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) { // ← the closure
        if _, err := trials.Start(r.Context(), r.FormValue("user"), r.FormValue("email")); err != nil {
            log.Error("start trial", "err", err)
            http.Error(w, "could not start trial", http.StatusInternalServerError)
            return
        }
        w.WriteHeader(http.StatusCreated)
    }
}

// in the router:
mux.Handle("POST /trials", handleStartTrial(trials, logger))

This is the most common DI style in Go HTTP code (Mat Ryer's well-known "How I write HTTP services" uses it) and in functional JavaScript. It's identical in spirit to NewService(…): dependencies arrive once, at wiring time; per-call data arrives per call. Choose it when the "object" would only have one method anyway.

2. React context: DI for components

React components have the same problem as handlers. A component that calls fetch("/api/trials") directly is hard-wired; you can't render it in a test or a design tool without a real server. Props are React's constructor injection. When passing a dependency through ten levels of props gets painful, React context is React's built-in way to inject it once, near the root:

api-context.jsx
import { createContext, useContext } from "react";

const ApiContext = createContext(null);
export const ApiProvider = ({ api, children }) => (
  <ApiContext.Provider value={api}>{children}</ApiContext.Provider>
);
export const useApi = () => useContext(ApiContext);

function TrialButton() {
  const api = useApi();                       // ← asks for "the API", not for fetch
  return <button onClick={() => api.startTrial()}>Start my trial</button>;
}

// main.jsx — the composition root of the UI:
//   root.render(<ApiProvider api={createHttpApi("/api")}><App /></ApiProvider>);
// a test — inject a fake:
//   render(<ApiProvider api={{ startTrial: async () => {} }}><TrialButton /></ApiProvider>);

Note how useApi() looks a little like a service locator (§12): the component asks for its dependency instead of receiving it. The difference is that the provider — chosen by whoever renders the tree — decides what it gets, and there's exactly one thing to look up. Keep contexts few and focused, the same way you keep interfaces small. (React 19 also lets you write <ApiContext value={api}> without .Provider.)

3. Go import cycles: DI as the way out

In Go, if package user imports billing and billing imports user, the build fails:

package example.com/app/billing
	imports example.com/app/user from b.go
	imports example.com/app/billing from u.go: import cycle not allowed

An Import cycle usually appears when two packages each want to call the other. The fix is the pattern from §04: the consumer declares a small interface, and main injects the real thing. billing stops importing user entirely:

Before and after: breaking the cycle
✕ before: import cycleuserbillingeach imports the other✓ after: one directionmainuserbillingEmailLookupinterfacesolid = importsdashed = satisfies (no import)
Left: user and billing import each other, so Go won't build. Right: billing declares the one method it needs (EmailLookup); *user.Service already has that method, so main can pass it in. The arrows now only point one way.
// package billing — no import of "user" anymore
type EmailLookup interface {
    Email(ctx context.Context, userID string) (string, error)
}

type Service struct{ users EmailLookup }

func NewService(users EmailLookup) *Service { return &Service{users: users} }

// package main — the only place that imports both
billingSvc := billing.NewService(userSvc) // *user.Service has an Email method → it fits
Practitioner§12

Anti-patterns and pitfalls

#1

Service locator: DI's evil twin

A Service locator is a global registry that code asks for its dependencies. It feels like DI — nothing calls sql.Open directly — but it brings back every problem from §02: the constructor no longer tells you what the code needs, and a missing registration is only found when that line runs.

✕ Don't
var registry = map[string]any{}

func (s *Service) Start(ctx context.Context, id string) error {
    store := registry["store"].(Store)   // hidden: not in any signature
    mail := registry["mailer"].(Mailer)  // panics at runtime if missing
    // …
}
✓ Do
func NewService(store Store, mail Mailer) *Service {
    return &Service{store: store, mail: mail} // needs are visible,
}                                             // checked by the compiler

The test: can you tell what a type needs by reading its constructor? If you have to read its body, it's a locator.

#2

Injecting the container

NewService(container) and then container.Get("db") inside is a service locator with extra steps. If you use a DI container (§09), only the composition root talks to it. Everything else receives plain, specific dependencies through its constructor.

#3

The giant constructor

func NewOrderService(db *sql.DB, cache Cache, mail Mailer, sms SMS, pay Payments,
    tax TaxCalc, stock Inventory, audit Audit, flags Flags, log *slog.Logger, now func() time.Time) *OrderService

Eleven dependencies isn't a DI problem — DI just made the real problem visible. This type does too many jobs: it's a Code smell pointing at the Single Responsibility Principle (see SOLID). Split it: maybe Checkout (payments, tax, stock) and Notifier (mail, SMS). Bundling the eleven into a Deps struct makes the call site tidier — fine for readability — but it hides the smell instead of fixing it.

#4

An interface for everything

Some codebases pair every struct with an interface "for testability": UserService + UserServiceInterface, defined by the producer, with one implementation and a generated mock nobody needs. That's double the code to read and navigate, with no seam anyone uses. Add an interface at the consumer, when a second implementation (often a test double) actually exists (§04). Concrete types are fine dependencies — a *slog.Logger or a *Codes struct doesn't need an interface in front of it.

#5

context.Context as a bag of services

ctx.Value("db") hides dependencies exactly like a service locator, and it isn't type-checked. Context values are for request-scoped data (request ID, trace ID, authenticated user) — see §10.

#6

Package-level globals and init()

var DB *sql.DB // set in init(), used everywhere

func init() {
    DB, _ = sql.Open("pgx", os.Getenv("DATABASE_URL")) // ← runs on import; errors ignored
}

init() runs automatically when the package is imported — including in every test of every package that imports it — and its errors can't be returned to anyone. A global DB is a hidden dependency for every function that touches it, and two tests that change it will interfere with each other. Build it in main, pass it in. (A global logger like Go's default slog is the one widely tolerated exception — but injecting a *slog.Logger costs little and gives you per-test and per-request loggers.)

#7

The typed-nil trap (Go)

A nil pointer stored in an interface is not a nil interface. A "nil check" on the interface passes, and the program crashes later:

var pg *postgres.Store            // nil pointer (we forgot to build it)
svc := trial.NewService(pg, mail, time.Now, logger)

// inside NewService:  if store == nil { … }   ← false! the interface holds (type=*postgres.Store, value=nil)
// later:             s.store.SaveTrial(…)     ← panic if SaveTrial touches s.db

Avoid it by building dependencies with constructors that return either a valid value or an error, and by never declaring a dependency variable you don't immediately assign. Don't rely on != nil checks inside constructors to save you.

#8

Constructors that do I/O

A NewClient() that connects to the network, runs migrations or starts goroutines makes the whole graph slow and fragile to build — and every test that builds one pays for it. Keep constructors cheap and side-effect free: store the dependencies and return. Do I/O in an explicit step (Ping, Start, Migrate) that main calls, where errors can be handled and logged. (Go's sql.Open follows this rule: it doesn't connect until the first query, or until you call db.PingContext.)

#9

Testing the mocks instead of the behavior

A test where every dependency is a strict mock and every assertion is "method X was called with Y" breaks on every refactor and proves little (§06). Prefer fakes and assertions on results; reserve call assertions for behaviors that are calls — "never emails on failure", "charges exactly once".

Practitioner§13

Check yourself

Predict the answer before you click. Each explanation points back to the section that covers it.

QUESTION 1 / 7
Which function has a hidden dependency?
// Signatures only; the comment says what each body does.
func A(prices []int) int                  // adds up the prices
func B(prices []int, taxRate float64) int // adds them up, then adds tax
func C(prices []int) int                  // adds them up, then adds tax from os.Getenv("TAX_RATE")
func D(prices []int, now time.Time) bool  // true if a sale is running at time now
QUESTION 2 / 7
What happens?
svc := &trial.Service{}
svc.SetStore(memStore)
// (no SetMailer call)
_, err := svc.Start(ctx, "u1", "ana@example.com")
QUESTION 3 / 7
In what order is this printed?
func run() {
    db := openDB();          defer fmt.Println("close db")
    m := newMailer();        defer fmt.Println("close mailer")
    srv := newServer(db, m); defer fmt.Println("stop server")
    fmt.Println("serving on", srv.Addr)
}
QUESTION 4 / 7
A test double keeps users in a map, and really saves and loads them. What is it?
QUESTION 5 / 7
trial.Service calls only SaveTrial. Where should the Store interface be declared?
QUESTION 6 / 7
You forget to register the *sql.DB provider. When do you find out with Uber Fx?
QUESTION 7 / 7
A singleton service stores the logged-in user's name in a field, set when it's built. What goes wrong?
Answered 0 / 7
Foundations§14

Cheat sheet

🔌
Pass it inDon't let code grab its own database, clock or logger. Hand them in — the lamp with a plug (§01).
🏗️
Constructor injection by defaultNewService(store, mailer, now, log) / new TrialService({ store, mailer, now, log }). The parameter list is the full list of needs (§03).
🧩
Small interfaces at the consumerDeclare only the methods you call, in the package that calls them. Accept interfaces, return structs (§04).
⏱️
The world is a dependencyTime, randomness, HTTP, files and env vars all get injected — or your tests get flaky (§07).
🧪
Fakes over mocksCheck results with fakes; check calls only when the call is the behavior (§06).
🌳
One composition rootmain() builds everything in dependency order and closes it in reverse with defer (§08).
♻️
Respect lifetimesLong-lived objects must never hold short-lived ones. Request data goes in parameters or context — services never do (§10).
🚫
Locators aren't DIIf you must read a type's body to know what it needs, it's a service locator (§12).
I want to…Do this
Test code that uses the current timeInject now func() time.Time / now: () => Date; pass a frozen or advancing fake
Test code that calls an HTTP APIInject base URL + client; httptest.NewServer in Go, a fake fetch in JS
Test code that reads files (Go)Accept fs.FS; pass fstest.MapFS in tests, os.DirFS(dir) in production
Swap Postgres for something elseSmall interface in the consumer; a new adapter that implements it; change one line in main
Shut down cleanlydefer x.Close() right after creating x; stop the HTTP server first
Pass a per-request value deep downAn explicit parameter, or context.WithValue with a private key type for request data only
Tame a 10-parameter constructorSplit the type — it's doing too many jobs
Decide on a DI framework (Go)Wire by hand (split main into helpers if it grows); Fx if your org standardizes on it. Google Wire is unmaintained since 2025
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Design PatternsGo Idioms as Patterns
Read →