sch39
Part 1 of 1 — G01 — Go Fundamentals for Developers

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

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

Setup

Initialize the workspace and two modules (run from the go-series/ folder):

bash
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 ./app

The 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

go
module github.com/sch39/go-series/g0101/textkit

go 1.25

Purpose: 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:

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:

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:

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:

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:

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

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