Documentation and APIs
Documentation and APIs
The boring API is a small exported surface with comments that go doc can print and example tests that go test runs. Compatibility is a promise: do not change a signature because a new name feels nicer. Change it when the old one is wrong, and then bump a major module version.
Mental model
In Go, the documentation is the comments above exported names, in the same file as the code. go doc reads those comments. There is no parallel docbook.
An example test is a function named Example, ExampleT, or ExampleT_M in a _test.go file. If it has an // Output: comment, go test compiles the function, runs it, and compares stdout to that comment. The example shows up in go doc and on pkg.go.dev.
The Go 1 compatibility promise is the standard library’s rule: exported signatures stay. Your module is not the standard library, but the same habit is how a desk API lasts more than one quarter.
Worked examples
Directory layout:
desk/
go.mod
ticket/ticket.go
ticket/example_test.go
Save go.mod at desk/:
module example.com/desk
go 1.27
Case 1: Package and function comments that go doc prints
Save as ticket/ticket.go. The package comment starts with Package ticket — go doc looks for that prefix.
// ticket.go
// Package ticket is the public API for opening desk tickets.
//
// Call Open, then Label. Zero values are not a valid ticket.
package ticket
import "fmt"
// Ticket is an opened desk ticket. Construct with Open, not a struct literal.
type Ticket struct {
id int
table int
}
// Open returns a ticket for the given id and table.
// It returns an error if id or table is not positive.
func Open(id, table int) (Ticket, error) {
if id <= 0 {
return Ticket{}, fmt.Errorf("ticket id must be positive")
}
if table <= 0 {
return Ticket{}, fmt.Errorf("table must be positive")
}
return Ticket{id: id, table: table}, nil
}
// Label is the one-line form operators print on a stub.
func (t Ticket) Label() string {
return fmt.Sprintf("ticket %d → table %d", t.id, t.table)
}From the desk/ directory (the module root):
go doc ./ticketOutput (word wrap may differ):
package ticket // import "example.com/desk/ticket"
Package ticket is the public API for opening desk tickets.
Call Open, then Label. Zero values are not a valid ticket.
func Open(id, table int) (Ticket, error)
type Ticket struct
One symbol:
go doc ./ticket.OpenOutput:
package ticket // import "example.com/desk/ticket"
func Open(id, table int) (Ticket, error)
Open returns a ticket for the given id and table. It returns an error if id
or table is not positive.
The comment is the API. If you would not say it to a new teammate, do not export the name.
Case 2: An example test is documentation that can fail
Save as ticket/example_test.go. External test package (ticket_test) documents the API the way a caller sees it.
// example_test.go
package ticket_test
import (
"fmt"
"example.com/desk/ticket"
)
func ExampleOpen() {
t, err := ticket.Open(7, 12)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(t.Label())
// Output:
// ticket 7 → table 12
}Run:
go test ./ticketOutput:
PASS
ok example.com/desk/ticket 0.002s
go test -v ./ticket names the example:
=== RUN ExampleOpen
--- PASS: ExampleOpen (0.00s)
PASS
ok example.com/desk/ticket 0.002s
If you change Label and forget the example, the test fails. That is the point. go doc ./ticket.Open will also list the example once it lives next to the package.
Case 3: A caller that only uses the stable surface
Save as main.go at the module root (desk/main.go):
// main.go
package main
import (
"fmt"
"os"
"example.com/desk/ticket"
)
func main() {
t, err := ticket.Open(7, 12)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(t.Label())
}Run:
go run .Output:
ticket 7 → table 12
The caller never mentions id or table fields. You can add an unexported note string to Ticket later without touching this file.
The trap
Renaming Open to NewTicket because a review asked for “consistency” is a compatibility break. Every caller recompiles with edits. This program still works — the damage is social:
Keep ticket/ticket.go from Case 1. Save this as main.go (replace Case 3’s main.go if it is still there):
// main.go
package main
import (
"fmt"
"os"
"example.com/desk/ticket"
)
func main() {
// After a “cleanup” that renamed Open → NewTicket, this line would not compile:
// t, err := ticket.NewTicket(7, 12)
t, err := ticket.Open(7, 12)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(t.Label())
}Run from the module root:
go run .Output:
ticket 7 → table 12
The trap is the commented line. If you already published Open, leave it. Add a name only when it does something different. Changing parameter order, changing a field from int to string, or returning *Ticket instead of Ticket are all new APIs. They belong in example.com/desk/v2, not in a quiet Tuesday commit.
Unkeyed struct literals of exported structs are also fragile. That is why Ticket’s fields stay unexported and Open is the constructor.
The boring rule
- Comment every exported name. Start package comments with
Package <name>. - Run
go doc ./…on the packages you ship. If you cannot explain a name in one sentence, hide it. - Add
Example…functions with// Output:for the two or three calls you want copied. - Do not change exported signatures, parameter order, or types on a whim.
- Prefer unexported fields plus a constructor over public structs that callers fill in.
- A new major module path is how you break things on purpose.
Try this
- Add
// ID returns the ticket id.and a methodID() int. Rungo doc ./ticket.Ticket.ID. - In
ExampleOpen, change the expected output to a wrong string. Rungo test ./ticketand read the diff. Restore it. - Add
ExampleOpen_badTablethat callsOpen(7, 0)and expects the error line.go test ./ticket. - Try to write
ticket.Ticket{id: 7}frommain.go. Read the compile error. That error is the API working.