DEV Community

Cover image for Go Project Structure for Humans: No, You Don't Need 15 Directories
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go Project Structure for Humans: No, You Don't Need 15 Directories


You started a Go project. You created a cmd/ folder, a pkg/ folder, an internal/ folder, and a utils/ folder. Your project has 200 lines of code and 12 directories. Something went wrong.

Here's the thing most "Go project structure" guides won't tell you: there is no official Go project structure. The Go team has never published one. That's not an oversight -- it's a deliberate choice. But it means beginners spend more time reorganizing directories than writing actual code. I've done it. You've probably done it too.

This post gives you three stages to grow into. You start simple, and you add structure only when real problems force you to. Not before.

The golden rule: don't organize code you haven't written yet

Every other guide on this topic starts with a fully baked project layout. Twenty directories, cleanly separated concerns, the works. And then you try to apply that layout to your 300-line CLI tool and spend an afternoon moving files between folders.

Stop doing that.

Start with main.go. Write your code. When you feel actual pain -- when you can't find things, when packages are getting too big, when you need to share code between two binaries -- then you restructure. Pain is the signal. Not anxiety about doing it wrong.

Rob Pike said it well: "A little copying is better than a little dependency." The same applies to structure. A little flatness is better than a little premature abstraction.

Give yourself permission to keep things flat. It's not lazy. It's practical.

Stage 1: The single-file project

This is where every Go project should start. One file, maybe two. Everything in the root of the module.

myapp/
  go.mod
  go.sum
  main.go
Enter fullscreen mode Exit fullscreen mode

That's it. Your main.go has your main() function, your types, your logic, everything. If you're building a CLI that fetches weather data, this is what it looks like:

weather-cli/
  go.mod
  go.sum
  main.go
Enter fullscreen mode Exit fullscreen mode

And main.go might be 150 lines that parse flags, hit an API, and print output. Perfect. Ship it.

If things get a bit longer, you can split into a couple of files in the same main package:

weather-cli/
  go.mod
  go.sum
  main.go
  fetch.go
  display.go
Enter fullscreen mode Exit fullscreen mode

All three files are package main. They compile together. No imports needed between them. This is completely normal Go code.

When this is fine: CLIs, scripts, small tools, prototypes, anything under roughly 500 lines. Most one-purpose utilities never need to leave this stage. Some of the best Go tools in the ecosystem are structured exactly like this. gofmt itself was a single file for years.

This is legitimate. Don't let anyone tell you it's not a "real" project because it doesn't have a cmd/ directory. The Go compiler doesn't care about your folder structure. It cares about packages.

Stage 2: The growing project

Your project is getting bigger. You've got users, you've got orders, you've got notifications. Your main.go is 800 lines and growing. Time to split into packages.

Here's the most important thing I can tell you about Go project structure: split by domain, not by type.

What most people do (wrong)

myapp/
  go.mod
  main.go
  models/
    user.go
    order.go
    notification.go
  handlers/
    user_handler.go
    order_handler.go
    notification_handler.go
  services/
    user_service.go
    order_service.go
    notification_service.go
  repositories/
    user_repo.go
    order_repo.go
    notification_repo.go
Enter fullscreen mode Exit fullscreen mode

This looks organized. It's not. It's a Java project in a Go trench coat.

Every feature touches every directory. Want to understand how orders work? Open four files in four directories. Want to add a field to a user? Touch models/user.go, handlers/user_handler.go, services/user_service.go, and repositories/user_repo.go. Everything depends on everything else. You've got circular dependency nightmares waiting to happen.

What you should do instead

myapp/
  go.mod
  main.go
  user/
    user.go
    store.go
    handler.go
  order/
    order.go
    store.go
    handler.go
  notification/
    notification.go
    sender.go
Enter fullscreen mode Exit fullscreen mode

Now each package owns its entire domain. The user package has its types, its storage logic, and its HTTP handlers. Want to understand how users work? Look in one directory. Want to delete the entire notification feature? Delete one folder. That's the power of domain-based packaging.

Inside user/user.go you'd have:

package user

type User struct {
    ID    string
    Email string
    Name  string
}

type Store interface {
    FindByID(id string) (*User, error)
    Create(u *User) error
}
Enter fullscreen mode Exit fullscreen mode

Inside user/store.go:

package user

import "database/sql"

type PostgresStore struct {
    db *sql.DB
}

func NewPostgresStore(db *sql.DB) *PostgresStore {
    return &PostgresStore{db: db}
}

func (s *PostgresStore) FindByID(id string) (*User, error) {
    // query the database
}

func (s *PostgresStore) Create(u *User) error {
    // insert into the database
}
Enter fullscreen mode Exit fullscreen mode

Clean. Self-contained. Each package has low coupling to the rest. The order package might depend on user.User as a type, but it doesn't need to know about user storage or user handlers. And because Go enforces this at the import level -- packages can't have circular imports -- the domain approach naturally keeps dependencies flowing in one direction.

Introducing internal/

When your project grows and you want to make it clear that certain packages are private to your module, wrap them in internal/:

myapp/
  go.mod
  main.go
  internal/
    user/
      user.go
      store.go
      handler.go
    order/
      order.go
      store.go
      handler.go
    notification/
      notification.go
      sender.go
    database/
      postgres.go
Enter fullscreen mode Exit fullscreen mode

The internal/ directory is enforced by the Go toolchain. Nothing outside your module can import packages under internal/. It's not just a convention -- the compiler will reject it. This is useful when you're building something that other people might depend on and you want to keep your internals private.

For a web service that nobody else imports, internal/ is still good practice. It signals intent: this code is implementation detail, not public API.

Stage 3: The production project

You've got multiple binaries. Maybe an API server, a worker process, and a migration tool. This is when cmd/ earns its place.

myapp/
  go.mod
  go.sum
  cmd/
    api/
      main.go
    worker/
      main.go
    migrate/
      main.go
  internal/
    user/
      user.go
      store.go
      handler.go
    order/
      order.go
      store.go
      handler.go
      worker.go
    notification/
      notification.go
      sender.go
    platform/
      database/
        postgres.go
      queue/
        rabbitmq.go
      config/
        config.go
Enter fullscreen mode Exit fullscreen mode

Each directory under cmd/ is a separate package main with its own main() function. They all share the business logic in internal/. You build them separately:

go build ./cmd/api
go build ./cmd/worker
go build ./cmd/migrate
Enter fullscreen mode Exit fullscreen mode

Notice what's not here: there's no pkg/ directory. The pkg/ convention comes from the Go standard library and a few large open-source projects. It means "code that external consumers can import." If you're not building a library that other Go modules will import, you don't need it. And honestly, even if you are building a library, your root package is probably the public API anyway. The Go standard library doesn't use pkg/. Neither should most projects.

I dedicated an entire chapter to production project patterns in my book -- including testing layout, deployment structure, and how internal/ interacts with build pipelines. The patterns above will get you far, but there's more to cover when you're running Go at scale.

When this level of structure makes sense: multiple binaries sharing code, teams working on the same repo, projects with 10k+ lines, services that have been running in production for months. Not starter templates. Not your weekend project.

The utils trap

Let's talk about utils/. Or helpers/. Or common/. These are code smells in Go.

Here's what usually happens. You write a function that formats a date for order receipts. You don't know where to put it, so you create utils/format.go:

package utils

func FormatDate(t time.Time) string {
    return t.Format("Jan 02, 2006")
}
Enter fullscreen mode Exit fullscreen mode

Then you call utils.FormatDate() from five different packages. Now utils is a grab bag that everything depends on. It has no cohesion. It grows forever. You've created a junk drawer.

The fix is simple: put the function where it belongs.

If only the order package uses it, put it in the order package:

package order

func formatDate(t time.Time) string {
    return t.Format("Jan 02, 2006")
}
Enter fullscreen mode Exit fullscreen mode

Unexported, right next to the code that uses it. No import needed.

If multiple packages need the same logic, ask yourself: is this actually a concept in my domain? Date formatting for invoices might belong in an invoice package. A function that retries HTTP calls might belong in an httpclient package. Give it a real name that describes what it does, not a bucket that describes what it is.

Before:

utils/
  format.go      // FormatDate, FormatCurrency, FormatAddress
  validate.go    // ValidateEmail, ValidatePhone
  retry.go       // RetryWithBackoff
Enter fullscreen mode Exit fullscreen mode

After:

internal/
  order/
    format.go    // formatDate, formatCurrency (unexported, used only here)
  invoice/
    format.go    // FormatAddress (used by invoice and shipping)
  httpclient/
    retry.go     // RetryWithBackoff
Enter fullscreen mode Exit fullscreen mode

Every function has a home that makes sense. No junk drawer.

A real example: three stages of the same project

Let's say you're building a service that manages user accounts and sends welcome emails. Here's how its structure evolves naturally.

Stage 1 -- you're prototyping:

welcomer/
  go.mod
  main.go          // 200 lines: HTTP server, user creation, email sending
Enter fullscreen mode Exit fullscreen mode

Works. Ship it internally. Get feedback.

Stage 2 -- it's growing and you're adding features:

welcomer/
  go.mod
  main.go          // just wiring: sets up server, connects to DB, starts listening
  user/
    user.go        // User type, Store interface
    store.go       // PostgresStore implementation
    handler.go     // HTTP handlers for create/get/list
  email/
    sender.go      // SendWelcome, SendPasswordReset
    templates.go   // email template logic
Enter fullscreen mode Exit fullscreen mode

You split when main.go hit 600 lines and you couldn't find anything. Each package owns its domain. main.go just wires them together.

Stage 3 -- it's in production and needs a background worker:

welcomer/
  go.mod
  cmd/
    api/
      main.go      // HTTP server
    worker/
      main.go      // processes email queue
  internal/
    user/
      user.go
      store.go
      handler.go
    email/
      sender.go
      templates.go
      worker.go    // queue consumer logic
    platform/
      database/
        postgres.go
      queue/
        redis.go
      config/
        config.go
Enter fullscreen mode Exit fullscreen mode

You added cmd/ because you now have two binaries. You added internal/ because you want to be explicit about what's private. You added platform/ for infrastructure concerns that don't belong to any domain.

Each transition was motivated by a real problem. Not by a template you found on GitHub.

Closing

Good project structure is grown, not planned. You don't need to guess what your project will look like in six months. You need to solve the problems you have today.

Start with main.go. Split into domain packages when files get too long or too tangled. Add cmd/ when you have multiple binaries. Add internal/ when you want to enforce privacy boundaries. That's the whole strategy.

Don't cargo-cult someone else's layout. Don't create directories for code you haven't written yet. Don't mistake complexity for quality.

Start simple. Split when it hurts. Not before.


Want to go deeper?

I wrote a book that covers everything in this series — and a lot more: error handling patterns, testing strategies, production deployment, and the stuff you only learn after shipping Go to production.

Available in:

Top comments (0)