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/ and internal/ 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 separate handlers/, models/, and services/ 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.