Let’s talk about organizing Go code. I’ve spent years working with Go, and I can tell you that how you arrange your files and folders matters just as much as the code inside them. A messy structure makes everything harder—finding code, testing it, and adding new features. A clean structure acts like a good map. It shows you where everything is and how it connects.
I’ll share some approaches that have worked for me, from how to lay out a whole project to how to think about the code inside each folder.
Start With a Clear Entry Point
Every application needs a starting place. In Go, we put this in the cmd directory. Think of cmd as the front door. Each application your project provides gets its own subfolder here. If you have a web API and a command-line tool, you’d have cmd/api and md/cli.
Here’s what that looks like. This is your main.go file, the very first thing that runs.
// cmd/myapp/main.go
package main
import (
"log"
"myproject/internal/app"
"myproject/internal/config"
)
func main() {
// Load settings
cfg, err := config.LoadFromEnv()
if err != nil {
log.Fatalf("Could not load config: %v", err)
}
// Build and start the application
myApp, err := app.New(cfg)
if err != nil {
log.Fatalf("Could not create app: %v", err)
}
if err := myApp.Run(); err != nil {
log.Fatalf("Application failed: %v", err)
}
}
This file should be short. Its job is to wire things together and handle any critical startup errors. All the real work happens elsewhere.
Protect Your Private Code
One of my favorite features in Go is the internal directory. Code placed inside a folder named internal can only be imported by other code within the same Go module. It’s like a private room in your house. Outside code can’t just walk in.
This is powerful. It lets you create clean APIs for others to use, while keeping your complex, changeable logic safely hidden. You don’t have to worry as much about breaking someone else’s code if you change an internal package.
// internal/user/service.go
package user
import (
"context"
"errors"
"myproject/internal/auth"
)
// Service holds the business logic for user operations.
type Service struct {
repo Repository
authService *auth.Service
}
// NewService creates a new user service.
func NewService(repo Repository, authSvc *auth.Service) *Service {
return &Service{
repo: repo,
authService: authSvc,
}
}
// CreateUser handles the specific rules for registering a new user.
func (s *Service) CreateUser(ctx context.Context, name, email string) (*User, error) {
// Example internal logic: check email format
if !isValidEmail(email) {
return nil, errors.New("invalid email format")
}
// Use the auth service (another internal package)
hashedPass, err := s.authService.HashPassword("temp_password")
if err != nil {
return nil, err
}
newUser := &User{
Name: name,
Email: email,
Password: hashedPass,
}
// Save using the repository
err = s.repo.Save(ctx, newUser)
return newUser, err
}
// isValidEmail is an unexported function. It's private to this package.
func isValidEmail(e string) bool {
// Simple check for demonstration
return len(e) > 3 && contains(e, "@")
}
Because this is in internal/user, another project cannot import myproject/internal/user. This service, with all its rules, is for your use only.
Share Library Code Publicly
Sometimes you write code that is so useful, you want other projects to use it. This is what the pkg directory is for. If internal is your private workshop, pkg is your public storefront. Code here is intended to be imported by other programs.
Be careful with what you put here. A pkg directory should contain stable, well-documented, and general-purpose code. Changing it can break other people’s projects.
// pkg/stringutil/helpers.go
package stringutil
import "unicode"
// Reverse returns its argument string reversed.
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
// ToTitleCase converts the first character of each word to uppercase.
func ToTitleCase(s string) string {
r := []rune(s)
makeUpper := true
for i, ch := range r {
if makeUpper && unicode.IsLetter(ch) {
r[i] = unicode.ToTitle(ch)
makeUpper = false
} else if unicode.IsSpace(ch) {
makeUpper = true
}
}
return string(r)
}
Another project can now import myproject/pkg/stringutil and use stringutil.Reverse("hello"). The key is that the pkg directory signals an intentional, public API.
Let Your Package Name Describe Its Job
When you create a package, give it a name that says what it does, not what it contains. Names like utils, helpers, or common are vague. They become dumping grounds for unrelated code. Instead, ask: “What is this package’s single job?”
- Instead of
utils, createvalidator,formatter, orsanitizer. - Instead of
helpers, createnotifier,converter, orcalculator.
When someone reads the import statement, they should immediately know the package’s purpose.
// Not this
import "myproject/utils"
// Do this
import "myproject/currencyconverter"
import "myproject/inputvalidator"
import "myproject/emailnotifier"
This applies inside your project too. A package named internal/order should contain everything related to the order process: the Order type, the logic for calculating totals, and the rules for shipping. It becomes a cohesive unit.
Keep Interfaces Close to Their Users
This is a subtle but important idea. In many languages, you define an interface where you implement it. In Go, I often do the opposite. I define the interface in the package that needs it, not the package that provides it.
Why? It reduces dependency. Your core business logic shouldn’t depend on the specific details of a database or an email service. It should depend on a simple contract—an interface.
// internal/payment/service.go
package payment
// PaymentProcessor is defined here, where it's used.
// The service doesn't care if it's Stripe, PayPal, or a test mock.
type PaymentProcessor interface {
Charge(amountCents int, token string) (transactionID string, err error)
Refund(transactionID string) error
}
type Service struct {
processor PaymentProcessor
}
func NewService(proc PaymentProcessor) *Service {
return &Service{processor: proc}
}
func (s *Service) ProcessOrder(total int, paymentToken string) error {
// Business logic here
id, err := s.processor.Charge(total, paymentToken)
if err != nil {
return err
}
// ... log the transaction ID, etc.
return nil
}
Now, in a separate package, perhaps in internal/gateways/stripe, you provide the concrete implementation.
// internal/gateways/stripe/client.go
package stripe
import "myproject/internal/payment"
// StripeClient implements the payment.PaymentProcessor interface.
type StripeClient struct {
apiKey string
}
func NewClient(apiKey string) *StripeClient {
return &StripeClient{apiKey: apiKey}
}
func (c *StripeClient) Charge(amountCents int, token string) (string, error) {
// Real Stripe API call here
return "ch_12345", nil
}
func (c *StripeClient) Refund(transactionID string) error {
// Real Stripe API call here
return nil
}
Your main function wires it all together. The payment package has no idea about Stripe. It only knows about the PaymentProcessor interface. This makes testing trivial—you can pass in a mock—and switching providers much easier.
Use Build Tags to Manage Variations
Sometimes you need different code for different situations. Maybe you have a Postgres implementation for production and an SQLite implementation for local development. Or you have special code for Windows vs. Linux. Build tags let you include or exclude files when compiling.
You write a special comment at the very top of the file.
// internal/database/postgres.go
//go:build postgres
package database
import (
"database/sql"
_ "github.com/lib/pq" // Postgres driver
)
type PostgresStore struct {
conn *sql.DB
}
func NewPostgresStore(connStr string) (*PostgresStore, error) {
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, err
}
return &PostgresStore{conn: db}, nil
}
// internal/database/sqlite.go
//go:build sqlite
package database
import (
"database/sql"
_ "modernc.org/sqlite" // Pure-Go SQLite driver
)
type SQLiteStore struct {
conn *sql.DB
}
func NewSQLiteStore(filePath string) (*SQLiteStore, error) {
db, err := sql.Open("sqlite", filePath)
if err != nil {
return nil, err
}
return &SQLiteStore{conn: db}, nil
}
Both files are in the same database package and can define the same functions. To build, you use tags:
go build -tags postgres . or go build -tags sqlite ..
This keeps related code together in your editor while letting the compiler pick the right one.
Structure Your Module for Clarity
At the root of your project is the go.mod file. This is the anchor. It defines your module’s name, which should match the repository path where your code lives, like github.com/yourname/yourproject.
The vendor folder is another tool. When you run go mod vendor, Go copies all your project’s dependencies into this folder. This means your build doesn’t need to download them from the internet. It’s great for ensuring perfectly reproducible builds or for environments with restricted network access. Just remember to add vendor/ to your .gitignore if you don’t plan to commit it, as it can get large.
Write Tests Beside Your Code
Testing in Go is straightforward. For every file something.go, you create a test file something_test.go in the same directory. This keeps tests close to the code they verify.
// internal/math/calculator.go
package math
type Calculator struct{}
func (c Calculator) Add(a, b int) int {
return a + b
}
func (c Calculator) Subtract(a, b int) int {
return a - b
}
// internal/math/calculator_test.go
package math
import "testing"
func TestCalculator_Add(t *testing.T) {
calc := Calculator{}
result := calc.Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
func TestCalculator_Subtract(t *testing.T) {
calc := Calculator{}
result := calc.Subtract(10, 4)
expected := 6
if result != expected {
t.Errorf("Subtract(10, 4) = %d; want %d", result, expected)
}
}
// A table-driven test for more comprehensive cases
func TestCalculator_Add_Table(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -1, -1, -2},
{"mixed numbers", 5, -3, 2},
{"zero", 0, 10, 10},
}
calc := Calculator{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calc.Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, result, tt.expected)
}
})
}
}
Running go test ./internal/math/... from your project root will find and run these tests. The ... tells it to test all packages under that path.
Putting It All Together
Let’s imagine a small project structure for a web service. This combines several of the patterns we’ve discussed.
my-web-service/
├── go.mod
├── cmd/
│ └── api/
│ └── main.go # Entry point
├── internal/
│ ├── config/
│ │ ├── config.go # Loads settings
│ │ └── config_test.go
│ ├── user/
│ │ ├── service.go # Business logic
│ │ ├── repository.go # Database interface
│ │ └── repository_test.go
│ ├── post/
│ │ ├── service.go
│ │ └── repository.go
│ └── server/
│ ├── server.go # HTTP router setup
│ └── handlers.go # HTTP handlers
├── pkg/
│ └── client/
│ ├── client.go # Public API client library
│ └── client_test.go
└── scripts/
└── migrate.sql # Database setup scripts
The flow is clear. main.go starts, loads config, creates services with their repositories, passes them to the server, and runs. The user and post packages are private domains. The pkg/client is a library others could use to talk to our API.
This isn’t a single rigid rule. It’s a set of tools. You might not need pkg for a simple app. You might group things differently. The goal is always the same: to make the code obvious. When a new developer joins your project, they should be able to look at the folder structure and have a good idea of where to find things and how they fit together. That’s the real benefit of good organization. It saves time and prevents confusion, letting you focus on writing the code that matters.