How to organize a Go project without over-engineering it

Go project structure: packages, cmd, internal, handlers, services, and repositories. When architecture helps and when it just adds noise.

Cover for How to organize a Go project without over-engineering it

My first Go project came after years with Java and Spring Boot. I did what any Java developer would do: created 20 packages, three layers of abstraction, interfaces for everything, and a pkg/ directory because I saw it in a repository with 30,000 GitHub stars. The project was a CRUD that read from PostgreSQL and returned JSON. Technically I wasn’t wrong, but all that architecture served absolutely no purpose. It was pure over-engineering, and it took me a while to realize it.

Go has a very clear philosophy about project structure, and that philosophy can be summarized in a phrase the community repeats constantly: “A little copying is better than a little dependency.” Simplicity isn’t a defect, it’s a design decision. And your project’s structure should reflect that.


Go’s philosophy: start flat, organize when it hurts

In Java, you start a project and before writing a single line of logic you already have src/main/java/com/company/project/controller/, service/, repository/, model/, dto/, config/, exception/… That’s the standard. Nobody questions it.

In Go, the starting point is radically different. A project can be a single main.go file and work perfectly well. There’s no framework-imposed convention because, in most cases, there’s no framework. No Maven forcing a directory structure on you. No Spring expecting to find your classes in specific packages.

This is disorienting at first — it disoriented me quite a bit, coming from Spring — but it has an enormous advantage: your project’s structure reflects the actual complexity of your project, not the complexity a framework forces you to anticipate.

The general rule that works:

  1. Start with everything in the main package.
  2. When a file gets too large, extract a package.
  3. When you have multiple binaries, use cmd/.
  4. When you need to protect internal packages, use internal/.
  5. If nothing hurts, don’t reorganize.

This isn’t laziness. It’s idiomatic Go.


The minimum viable structure: main.go and not much else

For a small project (a CLI tool, a simple microservice, a script that ends up in production longer than it should), this structure is perfectly valid:

my-project/
├── go.mod
├── go.sum
├── main.go
├── handler.go
├── service.go
└── repository.go

Everything lives in the main package. No subdirectories. No abstract interfaces. No pkg/. And it works.

// main.go
package main

import (
    "log"
    "net/http"
)

func main() {
    repo := NewPostgresRepo("postgres://localhost:5432/mydb")
    svc := NewTaskService(repo)
    handler := NewTaskHandler(svc)

    mux := http.NewServeMux()
    mux.HandleFunc("GET /tasks", handler.ListTasks)
    mux.HandleFunc("POST /tasks", handler.CreateTask)
    mux.HandleFunc("GET /tasks/{id}", handler.GetTask)

    log.Println("Server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
// handler.go
package main

import (
    "encoding/json"
    "net/http"
)

type TaskHandler struct {
    service *TaskService
}

func NewTaskHandler(s *TaskService) *TaskHandler {
    return &TaskHandler{service: s}
}

func (h *TaskHandler) ListTasks(w http.ResponseWriter, r *http.Request) {
    tasks, err := h.service.GetAll(r.Context())
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(tasks)
}

func (h *TaskHandler) CreateTask(w http.ResponseWriter, r *http.Request) {
    var t Task
    if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    created, err := h.service.Create(r.Context(), t)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(created)
}

func (h *TaskHandler) GetTask(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    task, err := h.service.GetByID(r.Context(), id)
    if err != nil {
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    json.NewEncoder(w).Encode(task)
}

This is a real service. It reads from a database, exposes an HTTP API, has separation of concerns. And it doesn’t need more structure than four files in the root.

The moment to reorganize comes when:

  • You have more than 10-15 files in the root and it’s hard to find things.
  • You need to reuse logic across more than one binary.
  • Another team or module needs to import your types.

If none of these things happen, stay flat. And being honest, you won’t win points for having more folders.


The cmd/ pattern: when you have multiple binaries

The cmd/ directory appears when your project produces more than one executable. A typical case: you have an API and a worker that processes queues, or an API and an admin CLI tool.

my-project/
├── cmd/
│   ├── api/
│   │   └── main.go
│   └── worker/
│       └── main.go
├── internal/
│   ├── task/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── repository.go
│   └── shared/
│       └── db.go
├── go.mod
└── go.sum

Each subdirectory inside cmd/ has its own main.go with package main. Each compiles to an independent binary.

// cmd/api/main.go
package main

import (
    "log"
    "net/http"

    "my-project/internal/task"
    "my-project/internal/shared"
)

func main() {
    db := shared.NewDB("postgres://localhost:5432/mydb")
    repo := task.NewPostgresRepo(db)
    svc := task.NewService(repo)
    handler := task.NewHandler(svc)

    mux := http.NewServeMux()
    mux.HandleFunc("GET /tasks", handler.List)
    mux.HandleFunc("POST /tasks", handler.Create)

    log.Fatal(http.ListenAndServe(":8080", mux))
}
// cmd/worker/main.go
package main

import (
    "log"

    "my-project/internal/task"
    "my-project/internal/shared"
)

func main() {
    db := shared.NewDB("postgres://localhost:5432/mydb")
    repo := task.NewPostgresRepo(db)
    svc := task.NewService(repo)

    log.Println("Worker starting...")
    svc.ProcessPendingTasks()
}

The key is that cmd/ only contains the entry point. The real logic is in internal/. The main.go files in cmd/ should be short files that initialize dependencies and start the program. If your main.go has more than 50-60 lines, you’re probably mixing configuration with business logic.

Don’t use cmd/ if you only have one binary. A main.go at the root is simpler and serves the same purpose. Sometimes the most boring solution is the right one.


internal/: the access control Go includes by default

internal/ is probably the most useful convention in Go and the least understood by those coming from other languages. It’s not a social convention like src/ in Java or lib/ in Ruby. It’s a compiler restriction. The go build tool itself prevents code outside your module from importing packages inside internal/.

my-project/
├── internal/
│   └── billing/
│       └── calculator.go    // Only accessible from my-project
├── pkg/
│   └── currency/
│       └── formatter.go     // Accessible by anyone
└── main.go

If another project tries to import "my-project/internal/billing", the compiler rejects it. No need to document that it’s private, no need to trust that people read the README. The compiler enforces it.

This gives you freedom to change the internal API without worrying about breaking external consumers. And in practice, that means you can refactor without fear. I think it’s one of those things you don’t value until you actually need it.

When to use internal/:

  • When your module exposes something public (a CLI, a library) and you want to protect the implementation.
  • When you work in a monorepo and want certain packages to only be used by your service.
  • By default. If you don’t have a reason for something to be public, put it in internal/.

When you don’t need internal/:

  • If your project is a binary that nobody imports. Technically it doesn’t matter where you put the packages because no one external will use them. But even then, internal/ communicates intent and is a good habit.

A practical structure for backends: handlers, services, repositories

This is where real decisions need to be made. When the project grows enough to need separate packages, the question is: do I organize by technical layer or by domain?

By technical layer (avoid in most cases)

internal/
├── handlers/
│   ├── task_handler.go
│   ├── user_handler.go
│   └── project_handler.go
├── services/
│   ├── task_service.go
│   ├── user_service.go
│   └── project_service.go
├── repositories/
│   ├── task_repo.go
│   ├── user_repo.go
│   └── project_repo.go
└── models/
    ├── task.go
    ├── user.go
    └── project.go

This is the direct translation of Spring Boot’s structure. And it works, I won’t say it doesn’t. But it has a fundamental problem in Go: it easily generates circular dependencies. The services package needs types from models and functions from repositories. The handlers package needs types from models and functions from services. Any refactoring that crosses layers becomes painful.

By domain (generally better)

internal/
├── task/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   └── model.go
├── user/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   └── model.go
└── platform/
    ├── db.go
    ├── config.go
    └── middleware.go

Each domain has everything it needs. task/ doesn’t need to import anything from user/ in most cases. If they need to communicate, you define an interface in the consuming package and let the other implement it.

// internal/task/service.go
package task

import "context"

type UserLookup interface {
    GetByID(ctx context.Context, id string) (User, error)
}

type Service struct {
    repo       Repository
    userLookup UserLookup
}

func NewService(repo Repository, ul UserLookup) *Service {
    return &Service{repo: repo, userLookup: ul}
}

The UserLookup interface is defined in the task package, not in user. This is idiomatic Go: interfaces are defined where they’re consumed, not where they’re implemented. It’s the opposite of Java, where you define the interface in the provider’s package.

This subtle inversion is what makes domain-based organization work without circular dependencies. I’ll admit it took me a while to internalize coming from the JVM world, but once you understand it, everything clicks.

If you want to go deeper into applying these principles more formally, I cover it in detail in the article on Go clean architecture.


The pkg/ debate: why many Go developers avoid it

If you search “golang project structure” on Google, the first thing you find is the golang-standards/project-layout repository. It has more than 50,000 stars and proposes a structure with cmd/, internal/, pkg/, api/, web/, configs/, scripts/, build/, and a dozen more directories.

The pkg/ directory is supposed to contain code that can be imported by external projects. The idea is that if something is in pkg/, it’s “public” and stable.

The problem: pkg/ has no special meaning to the compiler. Unlike internal/, which the compiler enforces, pkg/ is just a directory name. It adds no protection. It adds no functionality. It just adds an extra level of nesting to your imports.

// With pkg/
import "my-project/pkg/currency"

// Without pkg/
import "my-project/currency"

The second option is shorter and loses no information. If currency/ is in the module root and outside internal/, it’s already public by default.

Russ Cox, one of the leaders of the Go team, has explicitly said that pkg/ as a convention is unnecessary for most projects. The official recommendation is simple: use internal/ for private things, put public things in the module root or in subdirectories with descriptive names.

When pkg/ makes sense:

  • If your project has many directories at the root (configs, scripts, docs, deployments) and you want to group public Go code in one place to avoid losing it in the noise.
  • If you already use it and changing it would break imports in other projects.

When it doesn’t make sense:

  • In most projects. If your project is a binary (an API, a CLI), nobody imports your code. internal/ is all you need.
  • If you’re a small team and your code’s “public” consumers are your own services. internal/ and the module root cover that case.

Don’t copy golang-standards/project-layout without thinking

This point deserves its own section because it’s a trap many developers fall into — myself included at first — especially those coming from ecosystems with more rigid structures.

The golang-standards/project-layout repository is not an official Go standard. The name is misleading. The Go team itself has publicly said they don’t endorse it. Russ Cox opened an issue asking for the name to be changed because it creates confusion.

The repository shows a complete structure for a large, mature project. Applying it to a new microservice is like designing the architecture of a shopping mall to open a sandwich stand. Technically everything is in its place, but the navigation and maintenance cost isn’t justified.

What typically happens:

  1. Developer new to Go searches for “how to structure a Go project”.
  2. Finds golang-standards/project-layout.
  3. Copies the entire structure.
  4. Has a project with cmd/, internal/, pkg/, api/, web/, configs/, deployments/, test/, tools/, examples/, third_party/, build/, assets/, docs/
  5. 80% of those directories are empty or have one file.
  6. Every time a new file is created, they have to decide which of 15 directories it goes in.

I think structure should be a consequence of the project’s complexity, not an anticipation. Let the project tell you when it needs more structure. The situation is slowly changing: more and more people in the Go community understand that template isn’t a standard, and that helps.


When structure actually matters: medium and large projects

None of the above means structure doesn’t matter. There’s an inflection point where lack of organization starts to hurt. The symptoms are clear:

  • Files over 500 lines where it’s hard to find functions.
  • Circular dependencies between packages that shouldn’t know about each other.
  • Generic names like utils/, helpers/, common/ that become junk drawers.
  • Tests that import half the project because everything is coupled.
  • New team members who take days to understand where things go.

When you see these symptoms, it’s time to reorganize. And the principle is always the same: group by what changes together, not by what looks technically similar.

If every time you touch a “billing” feature you have to modify files in handlers/, services/, repositories/, and models/, those four files should be in a billing/ package. If changing the handler never requires changing another domain’s repository, they’re well separated.

To manage those dependencies between packages well in large projects, having a clear understanding of how Go modules work is fundamental.


Example trees: from simple project to multi-service

Simple project: CLI tool or small microservice

task-api/
├── go.mod
├── go.sum
├── main.go
├── handler.go
├── service.go
├── repository.go
├── model.go
└── README.md

Everything in package main. No subdirectories. Perfect for a service with one domain, few endpoints, and a single binary.

API project with multiple domains

order-service/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── order/
│   │   ├── handler.go
│   │   ├── handler_test.go
│   │   ├── service.go
│   │   ├── service_test.go
│   │   ├── repository.go
│   │   ├── repository_test.go
│   │   └── model.go
│   ├── product/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   └── model.go
│   ├── customer/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── model.go
│   └── platform/
│       ├── config/
│       │   └── config.go
│       ├── database/
│       │   └── postgres.go
│       ├── middleware/
│       │   ├── auth.go
│       │   ├── logging.go
│       │   └── recovery.go
│       └── server/
│           └── server.go
├── migrations/
│   ├── 001_create_orders.sql
│   └── 002_create_products.sql
├── go.mod
├── go.sum
├── Makefile
├── Dockerfile
└── README.md

Domain-based organization inside internal/. Tests go alongside the code they test (this is Go convention, not in a separate test/ directory). The platform/ package groups cross-cutting infrastructure: configuration, database, middleware.

For a concrete example with endpoints, middleware, and PostgreSQL, see the article on building a REST API with Go.

Multi-service project (monorepo)

ecommerce/
├── cmd/
│   ├── api/
│   │   └── main.go
│   ├── worker/
│   │   └── main.go
│   └── migrator/
│       └── main.go
├── internal/
│   ├── order/
│   │   ├── handler.go
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── events.go
│   │   └── model.go
│   ├── inventory/
│   │   ├── service.go
│   │   ├── repository.go
│   │   ├── consumer.go
│   │   └── model.go
│   ├── notification/
│   │   ├── service.go
│   │   ├── email.go
│   │   └── templates.go
│   └── platform/
│       ├── config/
│       │   └── config.go
│       ├── database/
│       │   └── postgres.go
│       ├── messaging/
│       │   └── kafka.go
│       ├── middleware/
│       │   ├── auth.go
│       │   └── logging.go
│       └── observability/
│           ├── metrics.go
│           └── tracing.go
├── migrations/
│   ├── 001_create_orders.sql
│   └── 002_create_inventory.sql
├── deployments/
│   ├── docker-compose.yml
│   └── k8s/
│       ├── api.yaml
│       └── worker.yaml
├── scripts/
│   └── seed.go
├── go.mod
├── go.sum
├── Makefile
└── README.md

Three binaries in cmd/. Clearly separated domains. Shared infrastructure in platform/. Deployment files in deployments/. Auxiliary scripts in scripts/.

Notice that even in a project of this size, the structure remains reasonably flat. No pkg/. No api/ with separate OpenAPI definitions. No third_party/. Each directory exists because it has a clear purpose, not because a template suggested it.


Packages: the unwritten rules

Beyond directory structure, there are conventions about the packages themselves that will save you problems:

Package names

  • Short and descriptive: task, order, auth. Not taskmanager, orderprocessing, authentication.
  • No redundant prefixes: The task package doesn’t need its types called TaskService or TaskHandler. In Go, you use them as task.Service and task.Handler. The package name already gives context.
  • Never utils, helpers, common, shared: These names communicate nothing. If you have currency formatting functions, create a currency package. If you have validators, a validation package. The name should say what it does, not that it’s “useful”.
// Bad
utils.FormatCurrency(amount)

// Good
currency.Format(amount)

Circular dependencies

Go doesn’t allow circular dependencies between packages. If order imports customer and customer imports order, it won’t compile. Period.

This seems like a limitation, and I’ll admit the first time I ran into it I thought it was. But in practice, it’s a gift. It forces you to think about the direction of dependencies from the start. The solutions are:

  1. Interfaces in the consumer: As we saw before. The package that needs something defines a minimal interface and the other implements it.
  2. Intermediate package: If two packages need common types, extract those types to a third package that both import.
  3. Reorganize: Sometimes a circular dependency indicates two packages should be one.

One file per responsibility

Don’t make giant files. But don’t create one file per function either. The practical rule: one file per main concept or type.

internal/order/
├── handler.go       // HTTP handlers for orders
├── service.go       // Business logic
├── repository.go    // Data access
├── model.go         // Types: Order, OrderItem, OrderStatus
├── events.go        // Domain events: OrderCreated, OrderShipped
└── validation.go    // Order-specific validation rules

Each file has between 50 and 300 lines. If it exceeds 400, it’s probably doing too much.


Conclusion: let the project tell you when it needs more

I believe a Go project’s structure should be the minimum necessary for the team (or just you) to work productively. Not the minimum possible, not the maximum you can imagine. The minimum necessary.

Start flat. Extract packages when you notice friction. Use internal/ by default for everything that doesn’t need to be public. Use cmd/ when you have more than one binary. Organize by domain, not by technical layer. Avoid pkg/ unless you have a concrete reason. And please, don’t copy the entire golang-standards/project-layout for a four-endpoint microservice.

The best structure is the one a new teammate understands in five minutes. If they need a 20-box diagram to know where to create a file, something has gone wrong.

Go was designed so that code is boring to read. And being honest, having your project structure be boring too is probably the best thing that can happen to it.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved