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.
The 30-second version
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:
🔒 sql.Open("pgx", …)🔒 time.Now()🔒 mail.Send(…)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
- Why "reaching out" hurts (§02), and how to fix it with constructor injection (§03).
- The Go and JS tools that make it pleasant: small interfaces (§04), other injection styles (§05).
- The big payoff — fast, reliable tests with fakes (§06–§07).
- Where all the real pieces get created:
main()(§08). - Staff-level topics: DI frameworks (§09), lifetimes (§10), functional DI and React (§11).
- The ways it goes wrong (§12), a quiz (§13) and a cheat sheet (§14).
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?
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:
Why hidden dependencies hurt
Now try to write a test that checks one simple fact: "the trial ends exactly 14 days from now".
- It needs a real Postgres. The first query connects to whatever
DATABASE_URLpoints at. No database, no test. - It sends a real email to whatever address you use — every time the test runs.
- You can't know the expected answer.
time.Now()is different on every run, so what exact date do you compare against? - The code is random. You can't assert "the email contains code WELCOME-0042".
- 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.
Constructor injection: pass it in
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).
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}
The result, in both languages
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:
| Before | After | |
|---|---|---|
| Which database? | The handler (calls sql.Open) | Whoever calls NewService |
| What time is it? | time.Now() inside | The now function passed in |
| Which logger? | The global log package | The *slog.Logger passed in |
| Can a test swap them? | No | Yes — 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.
Interfaces at the consumer
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
Storetells 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:
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:
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.
// 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// package trial
type Store interface { // just what trial uses
SaveTrial(…) error
}
// package postgres
func NewStore(db *sql.DB) *Store // return the structJavaScript: 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.
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.
| Style | How it arrives | Kept for | Use it for |
|---|---|---|---|
| Constructor | NewService(store, …) | The object's whole life | Almost everything: databases, clients, loggers |
| Parameter (method) | svc.Export(ctx, w) | One call | Things that change per call: the request, where to write output |
| Setter | svc.SetMailer(m) after creation | Until replaced | Rarely — see below |
| Function | NewService(…, time.Now) | Like constructor | Single-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:
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)svc := NewService(store, mailer, time.Now, logger)
// Forget an argument → compile error:
// not enough arguments in call to NewServiceWith 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 IDsOptional 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.
Testing seams: fakes, stubs and mocks
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:
Fake
A simpler, working version. A store backed by a map instead of Postgres. You check the result afterwards ("is u1 saved?").
Stub
A canned answer. "SaveTrial always fails with db down." Used to push the code down one path, usually an error path.
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.
svc := NewService(postgres.NewStore(db), smtp.New(addr), time.Now, jsonLogger)
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
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.
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.
Injecting time, randomness, HTTP and files
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") == true2. 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 runThe 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.
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 disk5. 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:
func NewMailer() *Mailer {
return &Mailer{
addr: os.Getenv("SMTP_ADDR"),
}
}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.
The composition root: wiring it all in main()
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.
nothing yet — start with a node that needs nothing- appears once everything is built
time.Now, is just a function — nothing to build.) Try clicking trial.Service first to see what happens.The code
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:
- Stop the HTTP server → no new requests; wait for current ones to finish.
- Close the mailer → nobody can be sending email now.
- 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:
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 terminalDI containers: Wire, Fx, NestJS
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:
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:
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:
ctx.Value lookup fails only when that code path first runs, possibly hours later in production.Honest trade-offs
| By hand | Wire | Fx / dig | NestJS / Inversify | |
|---|---|---|---|---|
| Missing dependency found at | compile | generate (before compile) | startup | startup |
| Interface ↔ struct | Automatic (Go checks it) | wire.Bind | fx.As | Injection tokens |
| Can you read the wiring? | Yes, it's just code | Yes, generated code | No — it's computed at runtime | Partly (module lists) |
| "Go to definition" works? | Yes | Yes | Not through the container | Partly |
| Extra concepts to learn | None | Providers, sets, build tags | Provide, Invoke, lifecycle, groups | Decorators, modules, tokens, scopes |
| Maintained? | — (it's your code) | No (since 2025) | Yes | Yes |
| Pays off when | Almost always | Existing projects already using it | Many teams share modules in one big app | You already use the framework |
Lifetimes and request-scoped dependencies
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.
| Lifetime | One instance per… | Examples | By hand in Go |
|---|---|---|---|
| Singleton | whole program | DB pool, HTTP client, logger, trial.Service | Built once in main |
| Scoped (per request) | request | the logged-in user, a request-ID logger, a DB Database transaction | Built inside the handler or Middleware |
| Transient | every use | a validator, a buffer | Constructor called each time you need one |
Send a few requests and watch which instances are shared:
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:
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")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:
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:
- Explicit parameters —
Record(ctx, user, action). Most readable; the compiler checks it. context.Contextvalues — 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.
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:
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 allowedAn 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:
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 fitsAnti-patterns and pitfalls
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.
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
// …
}func NewService(store Store, mail Mailer) *Service {
return &Service{store: store, mail: mail} // needs are visible,
} // checked by the compilerThe test: can you tell what a type needs by reading its constructor? If you have to read its body, it's a locator.
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.
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) *OrderServiceEleven 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.
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.
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.
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.)
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.dbAvoid 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.
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.)
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".
Check yourself
Predict the answer before you click. Each explanation points back to the section that covers it.
// 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 nowsvc := &trial.Service{}
svc.SetStore(memStore)
// (no SetMailer call)
_, err := svc.Start(ctx, "u1", "ana@example.com")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)
}Cheat sheet
NewService(store, mailer, now, log) / new TrialService({ store, mailer, now, log }). The parameter list is the full list of needs (§03).main() builds everything in dependency order and closes it in reverse with defer (§08).| I want to… | Do this |
|---|---|
| Test code that uses the current time | Inject now func() time.Time / now: () => Date; pass a frozen or advancing fake |
| Test code that calls an HTTP API | Inject 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 else | Small interface in the consumer; a new adapter that implements it; change one line in main |
| Shut down cleanly | defer x.Close() right after creating x; stop the HTTP server first |
| Pass a per-request value deep down | An explicit parameter, or context.WithValue with a private key type for request data only |
| Tame a 10-parameter constructor | Split 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 |