Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 02 / 09 30 MIN

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.

IdentifierVisible from another packageWhy
ParsePortYesExported function
parsePortNoLowercase first letter
HTTPClientYesCapitals anywhere after the first are irrelevant
PortYesExported package-level constant
Timeout time.DurationYesExported struct field, exported type
timeout time.DurationNoUnexported 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.

Lab 25 minutes

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.

  1. Add a second file to the greeting package that declares package greeting and calls Greet without importing anything. It should compile. Add a comment recording what that proves about the unit of compilation.
  2. Change the call in cmd/hello to greeting.greet and rebuild. Copy the error verbatim. Then go the other way: leave the call alone and rename the function itself to greet. That is a different message, and the difference is worth noticing. Put everything back afterwards.
  3. Create internal/greeting/internal/format with an exported function, and import it from cmd/hello. Note the error. Now import it from internal/greeting instead. 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.