Reading Go, and laying out a project
Go has one formatting style, one way to export a name, and a package system where the directory is the unit. This lesson covers the conventions you will be judged by, the project layout a Go reviewer expects, the fmt verbs the code already uses, and what has run before main.
On this page
On this page
- One formatting style, and no argument about it
- Capitalisation is the access control
- Names get shorter as their scope gets shorter
- The directory is the unit, and the layout follows
- What internal enforces, and what it does not
- Reading a Go file top to bottom
- The verbs in those format strings
- What has already run before main
What you will be able to do
- Apply Go's naming conventions, including what capitalisation actually controls
- Lay out a module with cmd, internal and library packages
- Explain what internal enforces and what it does not
- Read an unfamiliar Go file top to bottom and know where to look for what
- Say what runs before main, and why a blank import is not dead code
In Go the directory is the package, and a capital letter is the access control. There is no
public, no private, no export clause and no per-file visibility: a name beginning with an
upper case letter is reachable from any package that imports yours, and a name that does not
begin with one is reachable from every file in the same directory and nowhere else.
Those two facts decide the layout of every Go project you will read.
One formatting style, and no argument about it
Go source is formatted by gofmt, which has no style options, so arguments about brace placement,
indentation and import order do not happen in Go. Every file you open has the same shape, and
unformatted code is a signal that the author was not running the standard toolchain. The commands
and the check that belongs in continuous integration are covered in formatting, vet, modernisers
and linters.
Capitalisation is the access control
- exported identifier
A top-level name, struct field or method whose first letter is upper case. It is visible to every package that imports the one it is declared in. Anything else is visible only inside its own package, which in Go means its own directory.
In Java, visibility is a keyword attached to a member and the class is the unit of encapsulation,
with four levels to choose between. In TypeScript, visibility is an export list and the module,
usually one file, is the unit. Go has one bit, carried by the first letter, and the unit is the
directory.
The consequence a Java reader misses is that there is no class-private. Every file in a package sees every unexported field and method of every type declared there. Encapsulation in Go is a package boundary or it is nothing, so the way to hide an implementation detail from your own colleagues is to move it into its own package rather than to decorate it with a keyword.
Names get shorter as their scope gets shorter
Go names track the distance between where a value is declared and where it is last used. A
receiver is one or two letters, the same letters on every method of that type, and never this
or self; a loop index is i, a reader is r, a scratch buffer is buf. An exported
package-level identifier is read by strangers, so it gets whole words.
Package names carry the same discipline, and are chosen for how they read at the call site rather
than in isolation: lower case, one word, no underscores, singular, so chart.New rather than
chart.NewChart and bytes.Buffer rather than bytes.BytesBuffer. A function FormatChartLabel
in a package called util reads as util.FormatChartLabel(row) and puts the interesting word last;
moved and shortened, the same call is chart.Label(row) and the first six characters name the
subsystem. Avoid utils, common, helpers and base, which describe what a package is not.
The directory is the unit, and the layout follows
A module has go.mod at its root, which modules, versions and the
toolchain covers in full. Below
that, three kinds of directory account for almost every repository worth copying.
app/
go.mod
cmd/
report/
main.go // package main, one directory per executable
internal/
parse/ // yours alone, enforced by the go command
parse.go
parse_test.go
chart/ // importable by anyone who imports this module
chart.go
Each executable gets its own directory under cmd/, holding package main and as little else as
possible: flag parsing, wiring, and a call into a real package. Everything under internal/
belongs to you. Everything else at the top level is public API whether you meant it to be or not.
You will also see a pkg/ directory in the wild; it comes from a widely copied template rather
than from Go itself, the toolchain attaches no meaning to it, and you do not need it.
What internal enforces, and what it does not
An import path containing internal as a path element may be imported only by code in the tree
rooted at that internal directory's parent. The go command checks this while resolving imports,
so violating it is a build failure rather than a lint warning that somebody suppresses.
That nesting is the useful part. internal at the module root is importable by your whole
repository and nobody else; nested under one command it is scoped to that command. Capitalisation is an
independent axis, and the productive combination is both at once: an exported identifier inside an
internal package is fully exported, it simply has a smaller audience.
The guarantee is also narrower than it looks. It is not per-file or per-type privacy, and it is not a run time or security control: it stops an import, not a copy of your source. Unlike Java's package-private, which anything declaring the same package name can reach around, and unlike a TypeScript export list, which is erased before anything runs, this is a real compile-time boundary. That is its whole value and also its whole extent.
Reading a Go file top to bottom
Convention rather than the formatter fixes the order of a file, and it is stable enough to
navigate by. Imports come in groups separated by blank lines, standard library first, and gofmt
sorts within a group without moving anything between groups. Then package-level const and var,
then any func init, then types, then the constructor, then methods, then unexported helpers. A doc comment begins with
the name of the thing it documents, so it reads as a definition and the identifier is greppable.
// Package chart renders counted results as a fixed-width bar chart.
package chart
import (
"errors"
"fmt"
"io"
"strings"
)
// ErrEmpty is returned by WriteTo when a Chart has no rows.
var ErrEmpty = errors.New("chart: no rows")
const defaultWidth = 40
// A Chart holds labelled counts. Its zero value is an empty chart ready to use.
type Chart struct {
width int
rows []row
}
type row struct {
label string
n int
}
// New returns a Chart whose longest bar is width columns.
func New(width int) *Chart {
if width <= 0 {
width = defaultWidth
}
return &Chart{width: width}
}
// Add records one labelled count.
func (c *Chart) Add(label string, n int) {
c.rows = append(c.rows, row{label: label, n: n})
}
// WriteTo renders c to w and reports how many bytes it wrote.
func (c *Chart) WriteTo(w io.Writer) (int64, error) {
if len(c.rows) == 0 {
return 0, ErrEmpty
}
var total int64
for _, r := range c.rows {
bar := strings.Repeat("#", min(r.n, c.width))
n, err := fmt.Fprintf(w, "%-12s %s\n", r.label, bar)
total += int64(n)
if err != nil {
return total, fmt.Errorf("write row %q: %w", r.label, err)
}
}
return total, nil
}
Read that file the way a Go reviewer does. Chart, New, Add and WriteTo are the entire
public surface, because nothing else starts with a capital, and row will never appear in
anyone's editor completions. The receiver is c on every method. And WriteTo returning
(int64, error) is not an arbitrary shape: it matches an interface in the standard library, which
is the kind of signal a signature carries in Go once you have read a few packages.
The verbs in those format strings
fmt.Fprintf and its relatives take C-style verbs, and this handful covers nearly every line of Go
you will read.
| Verb | Prints |
|---|---|
%v %+v %#v | the default form, the same with struct field names, the same as Go syntax |
%s %q | the string, and the string double-quoted and escaped |
%d %f %t | integer, float, boolean |
%T | the type of the value rather than the value |
%w | wraps an error, and only inside fmt.Errorf |
%-12s | any verb takes a width, and - pads on the right |
Nothing checks the verb against the operand at compile time: %s on a struct holding an int prints
{%!s(int=3)}. Reach for %+v when logging a struct and %q when a value might be empty or carry
spaces, because quoting shows you that. Most mismatches are caught by go vet, in formatting, vet,
modernisers and linters.
A program's own arguments are os.Args, a []string whose first element is the path the binary was
invoked as, so the arguments you meant start at os.Args[1:]. Nothing parses them for you: the
standard flag package does, and the loganalyse
capstone builds a real command
line with it.
What has already run before main
Package-level variables are initialised before any function runs, in dependency order rather than
file order, so a var computed from another waits for it. Then every func init() in the package
runs, in the order the files reach the compiler, which for the go command is sorted by file name. A
package is fully initialised before any package importing it begins, so the graph settles from the
leaves up and main runs last. init takes no arguments, returns nothing, cannot be called, and may
appear more than once in a file.
That is the machinery behind an import with an underscore in front of it: import _ "github.com/lib/pq"
binds no name and exists so that package's init runs, registering something in a table somebody
else owns, such as a driver with database/sql, a decoder with image, or net/http/pprof's
handlers on the default mux.
What goes inside those files starts with values, constants and the zero value.
Check your understanding
Sign in to take this check
4 questions on this lesson, one at a time, with the reasoning for every option as soon as you answer. Each answer is marked on the server and stored against your account.
An account is free. There is no paid plan, no tier and nothing to buy.