๐Ÿ“ฆ Project Layout, Modules, and Explicit Dependency Wiring

Learning Objectives

By the end of this lesson you can:

  • Lay out a Go service with cmd/, internal/, and plain packages so boundaries are enforced by the compiler.
  • Keep go.mod honest: pin your Go version, prune unused dependencies, and know when go mod tidy is lying to you.
  • Wire config and dependencies from a single composition root, with no init() magic and no package-level mutable state.
  • Decide what belongs in internal/ versus a reusable package, using a rule you can defend in code review.

Quick Review

In the last module you built Run(ctx, items): bounded by errgroup.SetLimit, fail-fast through errgroup.WithContext, and index-stable when writing results. That function was pure: it took everything it needed as arguments and returned an error. Hold onto that shape. This lesson is about making the whole service look like that function: inputs in, output out, no hidden reach for a global.

Theory

Layout: three regions, one direction of dependency

myservice/
  cmd/
    api/
      main.go
  internal/
    config/
      config.go
    store/
      store.go
    httpapi/
      server.go
      handlers.go
  go.mod
  go.sum
  • cmd/api/main.go is the composition root. It reads config, constructs the store, constructs the HTTP server, and calls Run. Nothing else lives here.
  • internal/ is enforced by the toolchain: any package under internal/ can only be imported by code rooted at the parent of internal/. That is your "not a public API" declaration, and it is checked at compile time.
  • Plain packages outside internal/ are things you genuinely intend others to import.

The dependency arrow points one way: main imports internal/..., and internal/... never imports main and never imports each other in a cycle. If you find store importing httpapi, you have the arrow backwards: define an interface in the consumer, not in the provider.

[!TIP] Prefer few packages. A 300-line service with one internal/app package beats five packages with one file each. Split when a boundary earns its keep, not to look organized.

go.mod: keep it honest

go.mod declares your module path, your Go version, and your direct requirements. go.sum pins checksums of everything, direct and transitive.

  • Run go mod tidy after adding or deleting imports. It adds what is missing and drops what is unused.
  • Commit both go.mod and go.sum. A missing go.sum breaks reproducible builds for everyone else.
  • The go 1.2x line is not decoration: it sets language semantics and the default module graph behavior. Bump it deliberately.

Inline cloze check: the module path declared in go.mod is {{c1::the import prefix every package in the module is addressed through}}. A file under internal/store in module example.com/svc imports as {{c2::example.com/svc/internal/store}}.

Explicit wiring: the composition root

// cmd/api/main.go
func main() {
    if err := run(); err != nil {
        log.Fatal(err)
    }
}

func run() error {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    cfg, err := config.Load() // returns a struct, or an error; no globals
    if err != nil {
        return err
    }

    db, err := store.Open(ctx, cfg.DatabaseURL)
    if err != nil {
        return err
    }
    defer db.Close()

    srv := httpapi.NewServer(db, cfg.HTTPAddr, log.Default())
    return srv.Run(ctx)
}

Three properties matter here.

  1. Everything is a parameter. httpapi.NewServer takes the store, the address, and a logger. It does not call os.Getenv and it does not import your config package.
  2. run() returns an error. main does exactly one thing: exit non-zero on failure. That makes run testable without spawning a process.
  3. Ordering is visible. You can read top to bottom and see that config loads before the DB opens, and that the DB closes after the server stops.

Interfaces belong to the consumer

The server does not accept a *store.Postgres. It accepts the narrow interface it actually uses:

// internal/httpapi/server.go
type UserStore interface {
    UserByID(ctx context.Context, id int64) (store.User, error)
}

func NewServer(users UserStore, addr string, log *slog.Logger) *Server { /* ... */ }

Now the HTTP package has no compile-time dependency on the database package at all, and tests can pass a fake. Notice the interface is declared next to its use, not next to its implementation. That is the Go idiom, and it is the opposite of the "define an interface for every struct" habit from other ecosystems.

Config: struct in, error out

type Config struct {
    HTTPAddr    string
    DatabaseURL string
}

func Load() (Config, error) {
    cfg := Config{
        HTTPAddr:    cmp.Or(os.Getenv("HTTP_ADDR"), ":8080"),
        DatabaseURL: os.Getenv("DATABASE_URL"),
    }
    if cfg.DatabaseURL == "" {
        return Config{}, errors.New("DATABASE_URL is required")
    }
    return cfg, nil
}

Validate once, at the edge, and fail loudly. After this point nothing in the program should read the environment again. If something needs a value, it takes it as an argument. Cloze: reading configuration once in run() and passing it as arguments is called {{c3::explicit dependency injection}} (or wiring at the composition root), and the alternative, package-level variables read at import time, is {{c4::global state}}.

[!WARNING] Avoid init() for setup and package-level var db *sql.DB. Both hide ordering, both defeat tests, and init() failures surface as garbled panic stacks far from the cause.

Worked Examples

Example 1: Where does this helper go?

You have func ParseCursor(s string) (int64, error), used by one handler.

Ask: is it used by more than one package? No. Is it part of a public API? No. Put it in internal/httpapi/cursor.go as an unexported function. If a second package later needs it, then move it out. Moving a small function is cheap; a speculative internal/util package is not.

Example 2: Breaking an import cycle

store needs to log, and you are tempted to import httpapi because the logger lives there. Instead:

// internal/store/store.go
type Store struct {
    db  *sql.DB
    log *slog.Logger
}

func Open(ctx context.Context, url string, log *slog.Logger) (*Store, error) { /* ... */ }

Pass the logger in from main. No cycles, no shared package, and store now has a testable seam.

Example 3: Making a test-friendly boundary

In a test:

type fakeUsers struct{ err error }

func (f fakeUsers) UserByID(context.Context, int64) (store.User, error) {
    return store.User{ID: 1}, f.err
}

srv := httpapi.NewServer(fakeUsers{}, ":0", slog.Default())

The HTTP package compiles and tests with zero database. If you cannot write this file in five lines, your constructor takes too much.

[!NOTE] The internal/ rule is enforced by go build, including from test files in other modules. Use it freely; it costs nothing and it documents intent.

Discussion & Reflection

  1. Your service reads DATABASE_URL in three places. What breaks first in tests, and what is the smallest change that removes all three?
  2. A colleague adds internal/models imported by both store and httpapi. Is that a boundary or a shortcut? What happens when httpapi needs a JSON tag that store should not know about?
  3. Which of your packages could be deleted without touching main? If the answer is none, is that a problem?

Expert Corner

  • Rules of thumb. One cmd/ directory per binary. internal/ for everything not intended as a public API. Constructors that return (T, error), never a half-initialized struct.
  • Common mistake: constructor sprawl. If NewServer takes nine arguments, group them into a Deps struct or accept interfaces. Nine positional parameters is where wiring starts to rot.
  • Common mistake: go.mod drift. Stale requires linger until someone runs go mod tidy. Add it to CI as a check: run it and fail if git diff --exit-code go.mod go.sum is non-empty.
  • Common mistake: internal/ treated as security. It enforces structure, not access control. Do not put secrets behind it.
  • Rule of thumb. This sentence is worth memorizing: ==dependencies flow through arguments, not through imports of concrete implementations==. If a package must know how another is constructed, that knowledge belongs in main.

Summary & Next Steps

You now have a service skeleton: cmd/api owns construction and shutdown, internal/ holds the implementation with compiler-enforced boundaries, go.mod/go.sum stay tidy, and every dependency arrives as a parameter. run() error keeps main trivial and the wiring readable top to bottom. Interfaces sit next to their consumers, so the HTTP layer never imports the database layer.

Next up: net/http. You will fill in httpapi.Server with real handlers, middleware, JSON encoding, and graceful shutdown driven by the ctx your run() already passes in. The wiring you built here is exactly what makes that server testable with httptest.

[
  { "front": "What lives in cmd/api/main.go?", "back": "Only the composition root: read config, construct dependencies, call run()." },
  { "front": "What does the internal/ directory enforce?", "back": "Packages under internal/ are importable only by code rooted at internal's parent; the compiler enforces it." },
  { "front": "Which files must be committed for reproducible builds?", "back": "Both go.mod and go.sum." },
  { "front": "What does go mod tidy do?", "back": "Adds missing requirements and removes unused ones based on imports." },
  { "front": "Where should an interface be declared in Go?", "back": "In the package that consumes it, not the package that implements it." },
  { "front": "Why should main() contain only a call to run() and log.Fatal?", "back": "run() returning an error makes startup and shutdown testable without spawning a process." },
  { "front": "What is wrong with a package-level var db *sql.DB?", "back": "It is global mutable state: hidden ordering, untestable, and initialization failures are hard to trace." },
  { "front": "How should configuration be read?", "back": "Once, at the edge (in run()), validated, then passed down as explicit arguments." },
  { "front": "What is the signature rule for constructors?", "back": "They return (T, error), never a partially initialized struct." },
  { "front": "Why avoid init() for setup?", "back": "It hides execution order and produces panic stacks far from the real cause." },
  { "front": "How do you break an import cycle where store needs a logger?", "back": "Pass the logger in from main as a constructor argument instead of importing the other package." },
  { "front": "What does the go 1.2x line in go.mod control?", "back": "Language semantics and default module graph behavior; bump it deliberately." },
  { "front": "What signals that a constructor takes too much?", "back": "Roughly nine or more positional parameters; group them into a Deps struct or accept interfaces." },
  { "front": "How can CI catch go.mod drift?", "back": "Run go mod tidy in CI and fail if go.mod or go.sum changed." }
]

Cloze recap: the module path in go.mod is {{c1::the import prefix for every package in the module}}, internal/ boundaries are {{c5::enforced by the compiler}}, and setup logic belongs in {{c6::the composition root in cmd/}} rather than in {{c4::global state}} or init().