BuildBot

From Source to Service

Project layout, modules & tooling

Lesson 2 of 5

What you'll learn

  • Understand go mod as the (much smaller) package.json equivalent
  • Lay out a service with cmd/ for binaries and internal/ for private code
  • Run the standard tooling gauntlet: gofmt, go vet, staticcheck

A Go project starts with one command:

// go mod init github.com/you/notesvc
// creates go.mod:
module github.com/you/notesvc

go 1.22

go.mod is package.json without scripts, without devDependencies, without a lockfile debate (go.sum is generated and committed). The module path doubles as the import prefix: code in internal/store is imported as github.com/you/notesvc/internal/store. Adding a dependency is just importing it and running go mod tidy — which also removes anything unused.

The layout that Go expects

There's no framework scaffold, but production Go repos converge on the same shape:

notesvc/
  go.mod
  cmd/
    notesvc/
      main.go        // wiring only: flags, config, mux, ListenAndServe
  internal/
    store/           // data access
    api/             // handlers (the closures from lesson 1)

Two conventions do the heavy lifting. cmd/<name>/main.go holds one main package per binary you ship — main stays thin, just wiring. Everything else lives in internal/, and this one is not a convention: the compiler rejects any import of an internal/ package from outside the module subtree that contains it. It's private enforced at build time — no #[internal] JSDoc pleading, no eslint rule.

No src/, no barrel files

Go has no src/ directory and no index-file re-export pattern. A directory is a package; its exported identifiers (capitalized names, from the primer) are its entire public API. Deep import paths are normal and idiomatic.

The tooling gauntlet

Three tools run in every serious Go CI pipeline, and locally before you commit:

// gofmt -l .        one true style; there is nothing to configure
// go vet ./...      catches real bugs: bad printf verbs, copied locks,
//                   unreachable code — ships with the toolchain
// staticcheck ./... third-party (go install honnef.co/go/tools/cmd/staticcheck@latest)
//                   the community linter: dead code, misuse of APIs, simplifications

gofmt ends the formatting conversation the way Prettier tried to — except there are zero options, so there is nothing to argue about. go vet is closest to typescript-eslint's correctness rules. staticcheck is the one install-it-yourself tool worth mandating.

The challenge models the part of this that surprises people: internal/ visibility. The rule is that a/b/internal/c may only be imported by code rooted at a/b.

internal/ visibility (JS model)

Run it. This models the compiler's internal/ rule: an import is legal only if the importer lives inside the directory that contains the internal/ folder. Watch which imports the 'compiler' rejects.

Loading editor…
Knowledge check

What enforces that code outside your module can't import internal/store?

Next: the service needs to remember things — SQLite through database/sql, and the cgo-or-pure-Go driver decision.

Saved on this device. Sign in to sync your progress everywhere.