Getting started with Go
Packages, Modules and Project Layout
How Go divides code into packages, why capitalisation decides visibility, and the cmd and internal layout a small backend service should start with.
You write a helper, name it parsePort, import the package from another
file, and the compiler says the name is undefined. Nothing is misspelled. The function
is right there, four lines above the call. The problem is the first letter.
Go has no public or private. Visibility is capitalisation,
and the rule covers every identifier in the language: functions, types, methods, struct
fields, constants and package-level variables. Rename parsePort to
ParsePort and the compiler finds it immediately. That rule is one sentence
long and it surprises nearly everyone, because it looks like a naming convention rather
than a language feature.
Two structures organise Go code. The package is what the compiler actually builds, and the module is what you version, import and depend on. Put them together in the right directory shape and the compiler starts enforcing your architecture for you.
A package is the unit of compilation, not a file
Put two files in one directory. The first declares package greeting and
defines a function. The second declares package greeting and calls that
function with no import line between them. That compiles, and the reason is worth
holding onto: the compiler builds packages, not files.
Every .go file in a directory must declare the same package name, and
the go command hands the whole set to the compiler at once. Any identifier at package
level is visible to every file in the package. So splitting a package across ten files
costs nothing, and adding a file to a directory changes what the rest of that directory
can see.
Two kinds of file are exempt. A file whose name ends in _test.go may
declare package greeting_test, which compiles as a separate package that
imports the one under test. And a //go:build constraint at the top of a
file can exclude it by platform or by tag, which is how one directory holds both
syscall_linux.go and syscall_windows.go.
The import path is not the directory on disk. It is the module path plus the path from the module root, so the rule you actually need is about modules, which come next.
Only package main produces a program
The package name tells the go command whether it is building a library or a binary.
package main with a top-level func main is a program. Any other
package name produces something other packages can import and nothing you can run.
The compiler enforces both halves of that. Two main functions in one
package collide:
$ go build ./...
# example.com/orders/cmd/api
cmd/api/main.go:5:6: main redeclared in this block
cmd/api/extra.go:5:6: other declaration of main
Mismatched package names in one directory stop the build before the compiler sees anything:
found packages api (api.go) and main (main.go) in /home/ana/orders/cmd/api
That second message is blunt on purpose. The go command cannot guess which of the two names is correct, so it refuses rather than picking one. Nine times out of ten you copied a file into the wrong directory.
An identifier is exported when its name starts with a capital
The rule is about the first character of the name, not the file, not the directory, and not who wrote it.
| Identifier | Visible from another package | Why |
|---|---|---|
ParsePort | Yes | Exported function |
parsePort | No | Lowercase first letter |
HTTPClient | Yes | Capitals anywhere after the first are irrelevant |
Port | Yes | Exported package-level constant |
Timeout time.Duration | Yes | Exported struct field, exported type |
timeout time.Duration | No | Unexported struct field |
When you get it wrong the compiler usually tells you what you meant:
cmd/api/main.go:10:23: undefined: greeting.loud (but have Loud)
That parenthetical is one of the friendlier messages in the toolchain. It exists because the go compiler sees the unexported name in the package it built and knows the only reason you cannot reach it is capitalisation.
Unexported is not the same as invisible. A struct field written in lowercase can be
set and read by any code in the same package, and encoding/json silently
skips it when encoding:
type Order struct {
ID int
Total int
secret string
}
// encoding/json.Marshal(Order{ID: 1, Total: 42, secret: "hidden"})
// produces {"ID":1,"Total":42}
If a field needs to appear in JSON, it needs a capital. The tag on the field controls the JSON name, not the visibility.
go.mod names the module, and go.sum records what you trusted
go mod init example.com/orders writes one file. The module path in it is
the prefix of every import path inside the module, which is why it looks like a URL even
when the code never leaves your laptop.
module example.com/orders
go 1.26.3
require github.com/google/uuid v1.6.0
The require line appears after the first dependency, added by
go mod tidy or by go get. Alongside it, the same commands
create go.sum:
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
go.sum is not a lock file in the npm sense. It records a hash for the
contents of each module version you have used, and the go command checks every download
against it. A maintainer who rewrites a tagged release breaks your next build, which is
the point. Commit both files. Do not edit go.sum by hand, and when it
conflicts during a merge, resolve it by running go mod tidy on the merged
go.mod rather than by choosing sides in the text.
A major version lives in the import path
Import paths are identities, and two different identities can coexist in one program. Go uses that to handle breaking changes: version 2 and above of a module has to say so in its path.
module github.com/example/orders/v2
go 1.24.0
Code that uses it imports github.com/example/orders/v2/store, while a
program still on version 1 imports github.com/example/orders/store. Both can
be satisfied in the same build, each with its own version, and neither is a special case
in the resolver.
Versions 0 and 1 are the exception: they carry no suffix. That is why the rule reads
as strange the first time. The path only gains /v2 when the major version
reaches 2, and it keeps it for /v3 and beyond.
internal is enforced by the compiler
Convention is the usual way to say "do not import this". Go can do better. A
directory named internal is importable only by code rooted at the parent of
that directory. The go command refuses the import, and the message says so directly:
$ go build ./...
# example.com/layout-lab/cmd/hello
cmd/hello/main.go:7:2: use of internal package example.com/layout-lab/internal/greeting/internal/format not allowed
That example shows the rule at its sharpest. A nested internal under
internal/greeting is reachable from
internal/greeting itself, and from nowhere else, not even from
cmd/hello in the same module. Nesting the directory narrows the audience,
which is a precise way to express "this is an implementation detail of one package".
What internal buys you is a smaller public surface. When someone imports
your module, they can reach everything exported outside internal and nothing
inside it, so you can refactor the inside freely. Treat it as an API boundary rather than
a security boundary: it stops accidents, not determined callers, since anyone can copy
the file.
A layout for a small service: cmd, internal, go.mod
Three directories are enough to start, and this shape scales further than people expect.
orders/
├── go.mod
├── go.sum
├── cmd/
│ └── api/
│ └── main.go
└── internal/
├── config/
│ └── config.go
└── store/
└── store.go
One directory under cmd/ per binary, each containing a package
main whose only job is to read configuration, wire the pieces together and call
something in internal. Keeping main thin is not tidiness for
its own sake: nothing in a main package can be imported, so logic that lands
there cannot be tested from outside and cannot be reused by a second binary.
Everything else goes in internal, at least at first. If you later decide
a package is genuinely useful to other modules, moving it out of internal is
a rename and a few import paths. The reverse move is harder, because by then someone
depends on it.
Two omissions are deliberate. There is no src/, because the module root
is the source root. There is no pkg/, because a directory whose name means
"public" without any enforcement is a promise nobody checks, and
internal is a promise the compiler checks. Flat is fine until a package
grows past a few hundred lines.
Build a two-package module, then try to break it in three ways.
mkdir -p /tmp/layout-lab/internal/greeting /tmp/layout-lab/cmd/hello
cd /tmp/layout-lab
go mod init example.com/layout-lab
Put a Greet(name string) string function in
internal/greeting and call it from cmd/hello. Confirm
go run ./cmd/hello works.
- Add a second file to the greeting package that declares
package greetingand callsGreetwithout importing anything. It should compile. Add a comment recording what that proves about the unit of compilation. - Change the call in
cmd/hellotogreeting.greetand rebuild. Copy the error verbatim. Then go the other way: leave the call alone and rename the function itself togreet. That is a different message, and the difference is worth noticing. Put everything back afterwards. - Create
internal/greeting/internal/formatwith an exported function, and import it fromcmd/hello. Note the error. Now import it frominternal/greetinginstead. Note that it works.
Then answer: for step 3, why does moving the import one directory deeper change the answer, and what does that let you express?
Show the solution and the reasoning
Step 1 works because the two files are one package. The compiler receives both and
resolves Greet within the package, so there is no import to write. If
that felt like it should not compile, the mental model to correct is the one where a
.go file is a module: it is not, it is a fragment of a package, and the
package is what gets built.
Step 2 has two outcomes depending on which end you change. Calling
greeting.greet from another package gives:
cmd/hello/main.go:10:23: undefined: greeting.greet (but have Greet)
The interesting part is the parenthetical. The compiler found a name that matches except for its first letter, which means the exported function exists in the compiled package and your call cannot reach it. Nothing about that error is about spelling, and reading it as a typo wastes a few minutes every time.
Renaming the function itself instead gives a message that sounds like a different problem:
cmd/hello/main.go:10:23: name greet not exported by package greeting
Same root cause, different wording, because the compiler is describing what it sees
from its side. In the first case there is an exported Greet and your call
does not match it. In the second there is an unexported greet and your call
is reaching for it. Both are capitalisation, and both are worth recognising on sight so
you do not go looking for a missing import.
Step 3 is the one worth sitting with. The rule is not "internal means private to the
module". It is "internal means private to the tree rooted at the directory that
contains it". For internal/greeting/internal/format, that tree starts at
internal/greeting, so the package is visible to the greeting package and
to anything below it, and invisible to cmd/hello even though both live in
the same module.
That gives you a second lever beyond exported and unexported. Capitalisation hides a
single identifier. A nested internal hides a whole package, which is what
you want when the package has to be exported for one collaborator and must stay out of
everyone else's reach. Reaching for a nested internal is almost always
better than exporting four helpers and asking people not to use them.
Progress
Mark this lesson done and it stays marked in this browser.