How Go Code Is Organized
How Go Code Is Organized
A module is the unit you version and download. A package is the unit you import and compile. The boring default is one module, one package main at the root, until a real boundary appears. Capitals are the public API.
Mental model
go.mod names the module (module desk or module example.com/desk). Every import path starts with that module path, then the directory: example.com/desk/greet.
A directory is one package. The package clause is the name importers use in code (greet.Hello), not the directory name — though they should match.
package main plus func main is a program. Any other package name is a library. Names that start with a capital letter are exported (visible to other packages). The rest are private to the package.
internal/ is enforced by the toolchain: only code rooted at the parent of internal may import it. cmd/ is a convention, not a rule: one directory per binary when you have more than one, or when the root is a library.
Worked examples
Case 1: Exported means a capital letter
Save as names.go. Both functions live in package main, so both are callable here. The names still teach the rule you will need the moment a second package exists.
// names.go
package main
import "fmt"
func OpenShift(name string) string {
return name + " is on"
}
func closeShift(name string) string {
return name + " is off"
}
func main() {
fmt.Println(OpenShift("Amina"))
fmt.Println(closeShift("Amina"))
}Run:
go run names.goOutput:
Amina is on
Amina is off
Another package could call OpenShift. It could not call closeShift. There is no public keyword. The first letter is the whole policy.
Case 2: A tiny two-package module
Three files, one module. Create a directory, then save each listing at the path in the comment.
go.mod:
module example.com/desk
go 1.27
Save as greet/greet.go:
// greet.go
package greet
import "fmt"
func Hello(name string) string {
return fmt.Sprintf("hello, %s", name)
}
func whisper(name string) string {
return "(" + name + ")"
}Save as main.go:
// main.go
package main
import (
"fmt"
"example.com/desk/greet"
)
func main() {
fmt.Println(greet.Hello("desk"))
}Run from the directory that contains go.mod:
go run .Output:
hello, desk
The import path is example.com/desk plus /greet. The identifier is greet.Hello. greet.whisper would not compile from main.go — try it once and read the error.
whisper is not dead code. Other files in package greet could call it. Privacy is per package, not per file.
Case 3: cmd/ and internal/
Same module path. Layout:
.
├── go.mod
├── cmd/desk/main.go
└── internal/ticket/ticket.go
go.mod is unchanged:
module example.com/desk
go 1.27
Save as internal/ticket/ticket.go:
// ticket.go
package ticket
import "fmt"
type Ticket struct {
ID int
Table int
}
func (t Ticket) Label() string {
return fmt.Sprintf("ticket %d → table %d", t.ID, t.Table)
}Save as cmd/desk/main.go:
// main.go
package main
import (
"fmt"
"example.com/desk/internal/ticket"
)
func main() {
t := ticket.Ticket{ID: 9, Table: 2}
fmt.Println(t.Label())
}Run:
go run ./cmd/deskOutput:
ticket 9 → table 2
example.com/desk/internal/ticket is importable from example.com/desk/.... A different module cannot import it. That is the whole point of internal/.
You do not need cmd/ for a single binary. Root package main (Case 2) is enough. Add cmd/desk when the repository also holds a library, or a second command.
The trap
Splitting a 60-line desk tool into pkg/, internal/, cmd/, and four packages named util, helpers, common, and types. Import paths get longer. Names get shorter. Nobody can find OpenShift.
This complete program is the whole application. It does not need a directory tree.
// one_package.go
package main
import "fmt"
type Shift struct {
Name string
On bool
}
func (s Shift) Line() string {
state := "off"
if s.On {
state = "on"
}
return s.Name + " is " + state
}
func main() {
fmt.Println(Shift{Name: "Bo", On: true}.Line())
}Run:
go run one_package.goOutput:
Bo is on
The other trap is the mirror image: exporting every name “so tests can see it,” or putting two packages in one directory (package greet next to package main). One directory, one package. Unexported tests use package greet in greet_test.go; that is a later chapter.
The boring rule
- One module per project until you publish two versioned units.
- Import path = module path + directory.
- Capital letter = API. Lowercase = package private.
- Start with
package mainat the root. Addgreet/when a name is reused. - Add
internal/when you need a wall against other modules. Addcmd/<name>when you have two binaries or a library plus a binary. - Do not invent
pkg/orutils/on day one.
Try this
- In Case 2, change
main.goto callgreet.whisper("desk"). Read the compiler error. ChangewhispertoWhisperand fix the call. - In Case 3, add
cmd/board/main.gothat also importsinternal/ticketand prints a different ticket. Rungo run ./cmd/board. - Rename the module path in
go.modtoexample.com/cafeand update every import. Rungo run .(orgo run ./cmd/desk) until it builds.