Go Project Structure — Package, Module, and Workspace
Oct 2026 · 3 min read
One folder = one package. One go.mod = one module. go.work combines local modules during development. The compiler controls visibility: lowercase is not visible across packages, internal/ cannot be imported from outside the subtree.
Problem
These three terms are often mixed up. As a result: wrong import paths, "private" helpers that turn out to be usable by other modules, or local dependencies wired up with replace and then committed.
Outcome
The reader can:
Distinguish package, module, and workspace.
Understand visibility: lowercase,
internal/, exported.Set up local modules in a single workspace without
replace.
Github Project
Here is the link to the complete code repo:
Implementation
Folder Structure
go-series/G01-01-project-structure/
├── go.work # workspace combining textkit + app
├── textkit/ # library module
│ ├── go.mod
│ ├── slugify.go
│ ├── slugify_test.go
│ └── internal/normalize/
│ └── normalize.go
└── app/ # application module
├── go.mod
└── cmd/slugify/main.goSetup
Initialize the workspace and two modules (run from the go-series/ folder):
mkdir -p G01-01-project-structure
cd G01-01-project-structure
# Initialize workspace at root
go work init
# Create textkit module directory and go.mod
mkdir -p textkit
cd textkit
go mod init github.com/sch39/go-series/g0101/textkit
cd ..
# Create app module directory and go.mod
mkdir -p app/cmd/slugify
cd app
go mod init github.com/sch39/go-series/g0101/app
cd ..
# Register both modules to the workspace
go work use ./textkit ./appThe workspace and modules are ready. Next, create the files in the Code Blocks below.
Code Blocks
Block 1: go.mod as module root
go-series/G01-01-project-structure/textkit/go.mod
module github.com/sch39/go-series/g0101/textkit
go 1.25Purpose: The module path becomes the prefix for all imports inside this module.
Block 2: Unexported helper package in internal/
go-series/G01-01-project-structure/textkit/internal/normalize/normalize.go:
package normalize
import "strings"
func String(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}Purpose: internal/ prevents imports from outside the textkit subtree, even though the function name appears in the module docs.
Block 3: Exported function using an internal helper
go-series/G01-01-project-structure/textkit/slugify.go:
package textkit
import (
"strings"
"unicode"
"github.com/sch39/go-series/g0101/textkit/internal/normalize"
)
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsNumber(r)
}
func Slugify(s string) string {
s = normalize.String(s)
var result strings.Builder
lastHyphen := false
for _, r := range s {
if isWordRune(r) {
result.WriteRune(r)
lastHyphen = false
} else if !lastHyphen && result.Len() > 0 {
result.WriteByte('-')
lastHyphen = true
}
}
return strings.TrimRight(result.String(), "-")
}Purpose: Demonstrates three levels of visibility: Slugify (exported), isWordRune (unexported), normalize.String (exported but internal-guarded).
Block 4: Another application imports the library module
go-series/G01-01-project-structure/app/cmd/slugify/main.go:
package main
import (
"fmt"
"os"
"github.com/sch39/go-series/g0101/textkit"
)
func main() {
inputs := os.Args[1:]
for _, input := range inputs {
fmt.Println(textkit.Slugify(input))
}
}Purpose: The app module imports textkit with the full path. It cannot import textkit/internal/normalize; the compiler rejects it.
Test Blocks
Test 1: Exported function works
go-series/G01-01-project-structure/textkit/slugify_test.go:
package textkit_test
import (
"testing"
"github.com/sch39/go-series/g0101/textkit"
)
func TestSlugify(t *testing.T) {
tests := []struct {
input, want string
}{
{"Hello World!", "hello-world"},
{"FOO---BAR", "foo-bar"},
{" trim ", "trim"},
}
for _, tt := range tests {
if got := textkit.Slugify(tt.input); got != tt.want {
t.Errorf("Slugify(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}Purpose: Slugify in the textkit module works, can be imported and called from another package.
Test 2: Internal package cannot be imported from outside
go-series/G01-01-project-structure/app/cmd/slugify/main_test.go:
package main_test
import (
"testing"
// "github.com/sch39/go-series/g0101/textkit/internal/normalize"
)
func TestCannotImportInternal(t *testing.T) {
// _ = normalize.String
// The compiler rejects import paths containing internal/
// This test verifies: if the import line is uncommented, compilation fails.
}Purpose: Verify the compiler prevents internal/ imports from outside the subtree.
Why It Works
Module path in go.mod becomes the prefix for all imports in the module. The Go compiler adds / automatically to folders. Files in textkit/internal/normalize/ have the import path github.com/sch39/go-series/g0101/textkit/internal/normalize.
Visibility: Identifiers with uppercase letters are exported from the package. A package whose path contains internal/X can only be imported from the module that contains internal/X. The compiler enforces this.
Workspace (go.work): Combines local modules with use ./path/to/module — Go treats local modules as if they were on the same level without a replace directive in go.mod.
Edge Cases
Module path typo: If the go.mod path is wrong, the import path in consumers must also change. There is no relative import.
internal/ at any level: foo/internal/bar cannot be imported from outside foo/. But foo/bar/internal/baz can only be imported from foo/bar/.
Unexported vs internal: Different — isWordRune (unexported) is still visible in godoc if it is in a public package. internal/ is not visible at all from outside the subtree.
Trade-offs
Strategy | Benefit | Cost |
|---|---|---|
One module, one folder | Simple | Not reusable, tight coupling |
Many modules in | Modular, reusable | More complex setup, cross-folder coordination |
Packages within a module | Fast compilation, shared | Visibility is hard to enforce at scale |
Best practice: Modules for reusable libraries or bounded domains. Packages within a module for internal organization.
Gotchas I Experienced
Thinking folder = module. Actually folder = package. One module can have many packages.
Forgetting to add a module to
go.work. Try to import, the compiler says "module not found" even though the file exists.
References
Effective Go, Packages: https://go.dev/doc/effective_go#package-names
Go spec, Packages: https://go.dev/ref/spec#Packages
Go modules reference: https://go.dev/ref/mod
Workspaces: https://go.dev/ref/mod#workspaces