How to Structure a Go Project
Go doesn't enforce a rigid project structure like Rails or Django. Instead, it gives you tools—packages, visibility rules, and a flat import system—and expects you to use them wisely. This freedom is...
Key Insights
- Go’s flat package structure and explicit imports make project organization critical—poor structure leads to import cycles and unmaintainable code faster than in other languages
- The
cmd/andinternal/directories are your most powerful organizational tools:cmd/for multiple binaries,internal/to enforce package boundaries and prevent external imports - Organize packages by feature domain rather than technical layers—a
user/package containing handlers, models, and logic is more maintainable than separatehandlers/,models/, andservices/packages
Go’s Philosophy on Project Structure
Go doesn’t enforce a rigid project structure like Rails or Django. Instead, it gives you tools—packages, visibility rules, and a flat import system—and expects you to use them wisely. This freedom is powerful but dangerous. A poorly structured Go project becomes unmaintainable quickly because Go’s explicit imports and lack of circular dependency support expose structural problems immediately.
Here’s the difference between a throwaway script and a production project:
# Simple script
hello/
├── main.go
└── go.mod
# Production-ready project
myapp/
├── cmd/
│ ├── server/
│ │ └── main.go
│ └── worker/
│ └── main.go
├── internal/
│ ├── user/
│ ├── order/
│ └── config/
├── pkg/
│ └── httputil/
├── go.mod
└── go.sum
The simple version works for scripts. The structured version scales to teams and complexity.
The Standard Go Project Layout
The Go community has converged on a standard layout that’s become the de facto pattern for serious projects. It’s not official, but it’s widely adopted because it solves real problems.
myproject/
├── cmd/ # Application entry points
│ ├── server/
│ │ └── main.go
│ └── cli/
│ └── main.go
├── internal/ # Private application code
│ ├── user/
│ ├── auth/
│ └── database/
├── pkg/ # Public libraries (optional)
│ └── validation/
├── api/ # API definitions (OpenAPI, protobuf)
├── web/ # Static web assets
├── configs/ # Configuration files
├── scripts/ # Build and deployment scripts
├── test/ # Integration tests
├── docs/ # Documentation
├── go.mod
└── go.sum
Here’s how a main.go uses this structure:
// cmd/server/main.go
package main
import (
"log"
"net/http"
"github.com/yourorg/myproject/internal/user"
"github.com/yourorg/myproject/internal/database"
"github.com/yourorg/myproject/internal/config"
)
func main() {
cfg := config.Load()
db := database.Connect(cfg.DatabaseURL)
userHandler := user.NewHandler(db)
http.HandleFunc("/users", userHandler.List)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The key insight: cmd/ contains thin entry points that wire together packages from internal/.
Package Organization: Feature vs. Layer
This is where most Go projects go wrong. Coming from MVC frameworks, developers instinctively create layer-based packages:
# Layer-based (problematic)
internal/
├── models/
│ ├── user.go
│ └── order.go
├── handlers/
│ ├── user_handler.go
│ └── order_handler.go
└── services/
├── user_service.go
└── order_service.go
This creates problems:
- Circular dependencies between layers
- Changes to one feature touch multiple packages
- No clear ownership boundaries
Instead, organize by feature domain:
# Feature-based (recommended)
internal/
├── user/
│ ├── handler.go
│ ├── service.go
│ ├── repository.go
│ └── user.go
└── order/
├── handler.go
├── service.go
├── repository.go
└── order.go
Here’s the circular dependency problem and solution:
// PROBLEM: Circular dependency
// internal/models/user.go
package models
import "myapp/internal/services" // models -> services
type User struct {
ID int
Name string
}
// internal/services/user_service.go
package services
import "myapp/internal/models" // services -> models
func CreateUser(u models.User) error {
// ...
}
// SOLUTION: Feature-based package
// internal/user/user.go
package user
type User struct {
ID int
Name string
}
// internal/user/service.go
package user
func Create(u User) error {
// Same package, no import needed
}
The Power of cmd/ and internal/
The cmd/ directory is for executable entry points. Each subdirectory becomes a separate binary:
cmd/
├── server/
│ └── main.go # Builds to myproject-server
├── worker/
│ └── main.go # Builds to myproject-worker
└── migrate/
└── main.go # Builds to myproject-migrate
Build them with:
go build -o bin/server ./cmd/server
go build -o bin/worker ./cmd/worker
go build -o bin/migrate ./cmd/migrate
The internal/ directory is special—Go’s compiler prevents external projects from importing anything inside it. This is crucial for encapsulation:
// internal/auth/token.go
package auth
// This package CANNOT be imported by external projects
// Only code within your module can use it
func GenerateToken(userID int) string {
// Implementation details you don't want exposed
}
If another project tries import "github.com/yourorg/myproject/internal/auth", they get a compile error. This lets you refactor internal code without worrying about breaking external consumers.
Configuration Management
Configuration deserves its own package and directory structure:
configs/
├── dev.yaml
├── staging.yaml
└── prod.yaml
internal/
└── config/
└── config.go
Here’s a practical configuration loader:
// internal/config/config.go
package config
import (
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
Server struct {
Port int `yaml:"port"`
Host string `yaml:"host"`
} `yaml:"server"`
Database struct {
URL string `yaml:"url"`
MaxConns int `yaml:"max_conns"`
} `yaml:"database"`
}
func Load() (*Config, error) {
env := os.Getenv("APP_ENV")
if env == "" {
env = "dev"
}
filename := "configs/" + env + ".yaml"
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// Environment variables override file config
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
cfg.Database.URL = dbURL
}
return &cfg, nil
}
This pattern lets you commit safe defaults while overriding sensitive values via environment variables in production.
Test Organization
Go’s testing story is simple: put _test.go files next to the code they test.
internal/user/
├── handler.go
├── handler_test.go
├── service.go
└── service_test.go
For test fixtures and data:
// internal/user/handler_test.go
package user
import "testing"
func TestCreateUser(t *testing.T) {
// Test data in testdata/ directory
// Go's testing package looks for this automatically
}
// testdata/ is ignored by go build
internal/user/testdata/
└── valid_user.json
Integration tests go in a separate test/ directory at the root:
test/
├── integration/
│ ├── user_test.go
│ └── order_test.go
└── e2e/
└── api_test.go
These use the _test package suffix to test public APIs:
// test/integration/user_test.go
package integration_test
import (
"testing"
"github.com/yourorg/myproject/internal/user"
)
func TestUserCreation(t *testing.T) {
// Integration test using real database
}
Complete Production Example
Here’s a full REST API project structure with all pieces in place:
ecommerce-api/
├── cmd/
│ ├── server/
│ │ └── main.go # HTTP server entry point
│ ├── worker/
│ │ └── main.go # Background job processor
│ └── migrate/
│ └── main.go # Database migrations
├── internal/
│ ├── user/ # User domain
│ │ ├── handler.go # HTTP handlers
│ │ ├── handler_test.go
│ │ ├── repository.go # Database access
│ │ ├── repository_test.go
│ │ ├── service.go # Business logic
│ │ ├── service_test.go
│ │ └── user.go # Domain models
│ ├── order/ # Order domain
│ │ ├── handler.go
│ │ ├── repository.go
│ │ ├── service.go
│ │ └── order.go
│ ├── database/ # Shared database utilities
│ │ ├── postgres.go
│ │ └── migrations/
│ ├── middleware/ # HTTP middleware
│ │ ├── auth.go
│ │ └── logging.go
│ └── config/
│ └── config.go
├── pkg/ # Public libraries (if needed)
│ └── validator/
│ └── validator.go
├── api/
│ └── openapi.yaml # API specification
├── configs/
│ ├── dev.yaml
│ ├── staging.yaml
│ └── prod.yaml
├── test/
│ └── integration/
│ └── api_test.go
├── scripts/
│ ├── build.sh
│ └── deploy.sh
├── .gitignore
├── go.mod
├── go.sum
├── Makefile
└── README.md
This structure supports multiple binaries, clear domain boundaries, testability, and team collaboration. Each package has a single responsibility, and the internal/ boundary prevents external coupling.
Practical Best Practices
Keep packages focused: A package should represent one concept. If you’re tempted to name it “utils” or “helpers”, you’re doing it wrong.
Avoid deep nesting: Go’s import paths get unwieldy. Prefer internal/user/ over internal/domain/user/entity/.
Use internal/ liberally: Unless you’re building a library, most code belongs in internal/. Only expose what you intend to support as a public API.
One package per directory: Go enforces this. Don’t fight it.
Cmd binaries should be thin: All logic belongs in packages. main.go just wires things together.
This structure isn’t dogma—it’s a starting point. Adapt it to your needs, but understand the principles: clear boundaries, explicit dependencies, and feature-based organization. Get this right, and your Go project will scale cleanly. Get it wrong, and you’ll be fighting import cycles and spaghetti code within weeks.