Designing for the Long Term

Updated

September 13, 2026

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.go

Output:

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:embed for 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

  1. Add func (t Ticket) Empty() bool that reports t.id == 0. Do not export a field to do it. Run main.go.
  2. Try ticket.Ticket{id: 7} from main.go and read the compile error. Keep the fields unexported.
  3. Add package shift as in Case 3 and print shift.Label("Amina", shift.Morning) from main. Keep ticket unaware of shift.
  4. List the exported names in ticket (go doc ./ticket). If a name is not for callers, unexport it.