Designing for the Long Term
Designing for the Long Term
The boring default is a small exported API, package boundaries that match real jobs, and compatibility you treat as a promise. The rest of this book is technique. This chapter is what still matters when the desk has been in production for a year.
Mental model
A package is a name the rest of the program can import. Everything exported (Open, Ticket, Label) is a promise. Everything unexported is yours to change on a Tuesday.
A boundary is a reason to split: “tickets” versus “shifts” versus main. Not “models” versus “helpers” versus utils. If two files always change together and nobody imports them separately, they are one package.
Compatibility means a caller that compiled against last month’s module still compiles. You can add a method. You can add an unexported field. You cannot rename Open, shuffle its parameters, or change Ticket to *Ticket without a new major version (example.com/desk/v2).
Worked examples
Layout:
desk/
go.mod
ticket/ticket.go
main.go
Save go.mod:
module example.com/desk
go 1.27
Case 1: A stable ticket API
Unexported fields, a constructor that validates, methods that return values. Callers cannot write struct literals that break when you add a field.
Save as ticket/ticket.go:
// ticket.go
package ticket
import "fmt"
// Ticket is an opened desk ticket. Use Open. Do not depend on the zero value.
type Ticket struct {
id int
table int
}
// Open returns a ticket for a positive id and table.
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
}
// ID is the ticket identifier.
func (t Ticket) ID() int { return t.id }
// Table is the table the ticket is for.
func (t Ticket) Table() int { return t.table }
// Label is the one-line stub.
func (t Ticket) Label() string {
return fmt.Sprintf("ticket %d → table %d", t.id, t.table)
}Save as 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 from the module root:
go run .Output:
ticket 7 → table 12
This is the surface you freeze. Internals can grow.
Case 2: Add behaviour without breaking callers
A year later you need a note on the ticket. Add an unexported field and a method. Open’s signature does not change. main.go does not change.
Save as ticket/ticket.go:
// ticket.go
package ticket
import "fmt"
// Ticket is an opened desk ticket. Use Open. Do not depend on the zero value.
type Ticket struct {
id int
table int
note string
}
// Open returns a ticket for a positive id and table.
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
}
// WithNote returns a copy with a note. The original ticket is unchanged.
func (t Ticket) WithNote(note string) Ticket {
t.note = note
return t
}
// ID is the ticket identifier.
func (t Ticket) ID() int { return t.id }
// Table is the table the ticket is for.
func (t Ticket) Table() int { return t.table }
// Label is the one-line stub.
func (t Ticket) Label() string {
if t.note == "" {
return fmt.Sprintf("ticket %d → table %d", t.id, t.table)
}
return fmt.Sprintf("ticket %d → table %d (%s)", t.id, t.table, t.note)
}The original main.go still runs:
go run .Output:
ticket 7 → table 12
A new caller can opt in:
// note.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)
}
t = t.WithNote("window")
fmt.Println(t.Label())
}Keep only one main in the directory. Replace main.go with that file, or run it as the module’s main.go. Output of go run .:
ticket 7 → table 12 (window)
Old binaries that called Open and Label still match Case 1. New code asks for a note. That is how a package lasts.
Case 3: A boundary that is a real job
A second package for shifts, imported by main, not dumped into ticket as “related desk stuff.” Save as shift/shift.go:
// shift.go
package shift
import "fmt"
// Name is a roster label such as "morning".
type Name string
const (
Morning Name = "morning"
Evening Name = "evening"
)
// Label prints the roster line for a person.
func Label(person string, n Name) string {
return fmt.Sprintf("%s · %s", person, n)
}Save as main.go (this binary talks to both packages; they do not import each other):
// main.go
package main
import (
"fmt"
"os"
"example.com/desk/shift"
"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())
fmt.Println(shift.Label("Amina", shift.Morning))
}Run:
go run .Output:
ticket 7 → table 12
Amina · morning
Do not import shift from ticket unless a ticket literally cannot exist without a shift. Cycles are a smell that the split was fake.
The trap
A utils package that every file imports. Six months later it holds string helpers, time parsing, and a half-written HTTP client. Names collide. Tests become a hairball.
This is the wrong home for Open:
// utils.go
package utils
import "fmt"
func OpenTicket(id, table int) string {
return fmt.Sprintf("ticket %d → table %d", id, table)
}// use_utils.go
package main
import "fmt"
func OpenTicket(id, table int) string {
return fmt.Sprintf("ticket %d → table %d", id, table)
}
func main() {
fmt.Println(OpenTicket(7, 12))
}Run the second file (it does not need utils):
go run use_utils.goOutput:
ticket 7 → table 12
Put Open in package ticket. Put main in package main. If you only have twenty lines, one package main is fine. Split when a name wants two homes, or when another binary must import the API without importing main.
The boring rule
- Export a constructor and methods. Keep fields unexported unless the type is a bag of options you intend to grow with keyed literals only.
- Add methods and unexported fields freely. Do not rename or retag exported signatures without
v2. - Split packages along jobs (
ticket,shift), not layers (models,helpers,utils). - Recap from the rest of the book:
gofmt,go vet,go test,govulncheck; errors as values; small interfaces;CGO_ENABLED=0; no reflect/unsafe/cgo by default; measure before you tune; secrets out of source;//go:embedfor files; stamp a version with-ldflags. - The code a stranger can read on Monday morning is the code you still want in a year.
Try this
- Add
func (t Ticket) Empty() boolthat reportst.id == 0. Do not export a field to do it. Runmain.go. - Try
ticket.Ticket{id: 7}frommain.goand read the compile error. Keep the fields unexported. - Add
package shiftas in Case 3 and printshift.Label("Amina", shift.Morning)frommain. Keepticketunaware ofshift. - List the exported names in
ticket(go doc ./ticket). If a name is not for callers, unexport it.