{} The Go Reference

Data · Web · Intermediate

Project Layout & Dependency Injection

cmd/internal/pkg layout, accept-interfaces dependency injection, wiring in main, config from env/flags, and why Go rarely needs a DI framework.

Data Intermediate ⏱ 4 min read Complete

🧩 Analogy

Dependency injection is hiring with a job description instead of a named person. A Service posts: “I need something that can Save and Get.” It doesn’t care whether the hire is a Postgres store, an in-memory map, or a test fake — only that it fits the description (the interface). main is the hiring manager who picks the actual candidate.

A standard-ish layout

Go projects converge on a small set of top-level directories. Nothing is enforced except internal/, but the convention makes any codebase navigable:

graph TD
ROOT["myapp/"] --> CMD["cmd/<br/>main packages (entry points)"]
ROOT --> INT["internal/<br/>private packages (the app guts)"]
ROOT --> PKG["pkg/<br/>optional: reusable public packages"]
CMD --> WIRE["main(): wire everything together"]
INT --> SVC["service, store, http handlers"]
  • cmd/myapp/main.go — one tiny main per binary. Its only job is to read config and wire dependencies.
  • internal/… — the bulk of your code (services, stores, handlers). The toolchain forbids other modules from importing it, so you can refactor freely.
  • pkg/…optional, only for genuinely reusable libraries you intend others to import. Most apps don’t need it; don’t cargo-cult it.

Accept interfaces, wire in main

The core idea: a Service depends on a Store interface, never on a concrete database. The real implementation is constructed in main; a fake is constructed in tests. Because Go satisfies interfaces structurally, the swap is free — no registration, no framework.

di.go — editable & runnable
package main

import (
"errors"
"fmt"
"sort"
)

// Store is the small interface the Service depends on.
// The Service never names a concrete database — only this contract.
type Store interface {
Save(id string, balance int) error
Get(id string) (int, error)
}

// Service holds business logic. It depends on the Store INTERFACE,
// not on any particular implementation.
type Service struct {
store Store
}

// NewService is the constructor: dependencies arrive as arguments.
func NewService(s Store) *Service {
return &Service{store: s}
}

func (svc *Service) Open(id string, opening int) error {
if opening < 0 {
	return errors.New("opening balance cannot be negative")
}
return svc.store.Save(id, opening)
}

func (svc *Service) Deposit(id string, amount int) (int, error) {
bal, err := svc.store.Get(id)
if err != nil {
	return 0, err
}
bal += amount
if err := svc.store.Save(id, bal); err != nil {
	return 0, err
}
return bal, nil
}

// memStore is an in-memory Store. In production this would be SQL-backed;
// in tests you would pass a fake. The Service cannot tell the difference.
type memStore struct{ data map[string]int }

func newMemStore() *memStore { return &memStore{data: map[string]int{}} }

func (m *memStore) Save(id string, balance int) error {
m.data[id] = balance
return nil
}

func (m *memStore) Get(id string) (int, error) {
bal, ok := m.data[id]
if !ok {
	return 0, fmt.Errorf("account %q not found", id)
}
return bal, nil
}

func main() {
// Wiring happens HERE in main: choose the implementation and inject it.
// Swap newMemStore() for a SQL store without touching Service at all.
svc := NewService(newMemStore())

_ = svc.Open("alice", 100)
_ = svc.Open("bob", 50)

bal, _ := svc.Deposit("alice", 25)
fmt.Println("alice balance:", bal) // 125

bobBal, _ := svc.Deposit("bob", 10)
fmt.Println("bob balance:  ", bobBal) // 60

ids := []string{"alice", "bob"}
sort.Strings(ids) // deterministic output
fmt.Println("accounts:", ids)
}

In a test you’d define a fakeStore (or reuse memStore) and pass it to NewService — no database, no network, instant. That is the entire payoff of “accept interfaces.”

Config from env and flags

Keep configuration out of code. Read it in main and pass concrete values down:

type Config struct {
	Addr        string
	DatabaseURL string
}

func loadConfig() Config {
	cfg := Config{
		Addr:        ":8080",
		DatabaseURL: os.Getenv("DATABASE_URL"), // placeholder source
	}
	flag.StringVar(&cfg.Addr, "addr", cfg.Addr, "listen address")
	flag.Parse()
	return cfg
}

func main() {
	cfg := loadConfig()
	db := mustOpenDB(cfg.DatabaseURL)   // construct dependencies
	svc := NewService(NewSQLStore(db))  // inject them
	srv := NewServer(cfg.Addr, svc)     // wire the HTTP layer
	log.Fatal(srv.Run())
}

💡 Plain functions beat DI frameworks in Go

You almost never need a DI framework in Go. Constructor injection plus a main that wires things up is enough, stays type-checked at compile time, and reads top-to-bottom with no hidden magic. Define interfaces where they’re used (in the consumer’s package), keep them small — often one or two methods — and let the concrete types live near the database or HTTP layer. When wiring grows large, a tool like Google’s wire generates the same plain code for you; a runtime reflection container is rarely worth its opacity.

See also

Next: stopping the server cleanly when it’s time to deploy — Graceful Shutdown.

Check your understanding

Score: 0 / 5

1. What lives in the internal/ directory of a Go project?

The Go toolchain enforces that anything under an internal/ directory is importable only by packages rooted at internal/'s parent. It's privacy at the package level: put your application's guts there so outside modules can't depend on them, keeping your public API (pkg/) small and stable.

2. How is dependency injection usually done in idiomatic Go?

Idiomatic Go DI is just passing arguments: a Service takes a Store INTERFACE in its constructor, main constructs the concrete Store and passes it in, and tests pass a fake. No container, no reflection, no annotations — just plain functions and small interfaces, which is why DI frameworks are rarely needed.

3. Why does accepting an interface (not a concrete type) make a Service testable?

Because the Service only knows the interface, you can substitute any implementation: the real SQL-backed store in production, a hand-written in-memory fake in tests. Since Go satisfies interfaces structurally, the fake needs no special registration — it just implements the methods, so tests stay fast and hermetic.

4. Where should you define an interface in idiomatic Go?

Go's convention is 'accept interfaces, return structs' and 'define the interface where it's consumed.' The Service package declares the small Store interface it needs; the database package returns a concrete *SQLStore. This keeps interfaces minimal and avoids a producer dictating a fat interface to all callers.

5. What belongs in cmd/myapp/main.go?

main is the composition root: it loads config (env/flags), builds the real Store/Service/Server, injects them, and runs. Logic lives in internal/ packages so it's testable; main just wires. One small main per binary under cmd/.

Comments

Sign in with GitHub to join the discussion.