How to Structure a Go Project

Flat vs Layered vs DDD

Structure Go projects using a layered approach with cmd for entry points, internal for private logic, and pkg for public libraries.

You hit the wall of main.go

You start a Go project. You create main.go. You write a function to fetch data. It works. You add an HTTP handler. You add a config loader. You add a database migration runner. Suddenly main.go has 400 lines. You try to extract the handler into a separate file, but the compiler rejects the import path. You capitalize the function name to export it, but now your package leaks implementation details. You've heard terms like "layered architecture" and "domain-driven design," but every blog post shows a different folder layout. Which one actually works for Go?

Go forces structure through its visibility rules. A package starting with a capital letter is public. Lowercase is private. The internal directory is a hard boundary: code inside it cannot be imported by code outside the module. This means your folder structure isn't just organization. It is a security policy enforced by the compiler. You can accidentally leak implementation details in Go if you put them in the wrong place. The goal of project structure is to make the hard things impossible and the easy things obvious.

Convention aside: gofmt is the standard. Run it on every save. It removes arguments about indentation and formatting. Focus on logic, not whitespace. Most editors integrate gofmt automatically. Trust the tool.

The flat structure

Here's the simplest possible structure: everything in the root directory.

// main.go
package main

import "fmt"

// greet formats a greeting string.
func greet(name string) string {
    // Sprintf handles the formatting.
    return fmt.Sprintf("Hello, %s", name)
}

func main() {
    // Print the result to stdout.
    fmt.Println(greet("World"))
}

This compiles and runs. It has zero cognitive overhead. You don't need to navigate folders to find code. The flat structure works perfectly for scripts, one-off tools, and small utilities under 500 lines. The compiler treats the entire directory as a single package. All files share the same namespace. You can reference functions across files without imports.

The flat structure fails when you need to share code. The main package cannot be imported by other packages. If you try to import main from a test or another tool, the compiler rejects it with import of package main is not allowed. You also lose the ability to use internal to hide implementation details. Everything is either public or private to the main package. When the project grows, you hit import cycles or namespace collisions.

Flat structure is honest. It admits the project is small. Don't pretend a script needs an architecture.

The layered structure

Here's the standard layered layout that scales from small services to large applications.

// cmd/server/main.go
package main

import (
    "log"
    "myapp/internal/handler"
    "myapp/internal/config"
)

// main initializes the application and starts the server.
func main() {
    // Load config early to fail fast if environment is misconfigured.
    cfg := config.Load()

    // Construct the handler, passing config so tests can mock it.
    h := handler.New(cfg)

    // Log the address before serving to confirm startup.
    log.Printf("Listening on %s", cfg.Addr)

    // Fatal logs and exits if Serve returns an error.
    log.Fatal(h.Serve(cfg.Addr))
}

The layered structure separates concerns into three zones. cmd holds entry points. Each binary gets its own directory under cmd with a main.go. This works because Go requires every executable to have a main package, and main packages cannot be imported. cmd isolates wiring code from business logic.

internal holds private code. The compiler enforces this boundary. If you try to import an internal package from outside the module, the compiler rejects the build with use of internal package not allowed. This is a feature. It lets you refactor aggressively without breaking external consumers. You can rename functions, change signatures, and reorganize files inside internal without worrying about downstream dependencies.

pkg holds public libraries. This directory is optional and debated. Use pkg only when you intend to share code across multiple modules or expose a library to the world. For most applications, internal is enough. Putting code in pkg signals that the API is stable and public. Treat it like a contract.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't hide errors. Return them. The caller decides how to handle them.

The layered structure scales. It supports multiple binaries, clear boundaries, and safe refactoring. It also adds overhead. You need to decide where each file belongs. You need to manage imports. It's the right choice when the project has distinct entry points and shared logic.

Domain-driven design in Go

Here's how a domain-driven layout isolates business rules from infrastructure.

// internal/domain/order.go
package domain

// Order represents a customer order with business invariants.
type Order struct {
    ID     string
    Items  []Item
    Status Status
}

// AddItem validates the item before adding it to the order.
func (o *Order) AddItem(item Item) error {
    // Enforce business rule: order cannot accept items after status is shipped.
    if o.Status == Shipped {
        return ErrOrderShipped
    }
    // Append the item to the slice.
    o.Items = append(o.Items, item)
    return nil
}

Domain-driven design (DDD) focuses on the business domain. The structure reflects bounded contexts. Each context has its own language, models, and rules. In Go, this often looks like internal/domain for core models, internal/application for use cases, and internal/infrastructure for external dependencies.

The domain layer contains pure business logic. It depends on interfaces, not concrete implementations. This follows the mantra "accept interfaces, return structs." The domain defines what it needs. Infrastructure provides it. This makes the domain testable and independent of frameworks.

Convention aside: Receiver names should be one or two letters matching the type. Use (o *Order), not (this *Order) or (self *Order). This is the Go style. It keeps code concise and readable.

DDD adds complexity. It requires discipline to keep layers clean. It's overkill for simple CRUD apps. It shines when the business logic is complex and spans multiple aggregates that evolve independently. Use DDD when the domain is the differentiator, not the technology.

DDD protects the core. Infrastructure changes. Frameworks come and go. The domain stays.

Pitfalls and compiler errors

Structure fights back when you get it wrong. The compiler catches many mistakes early.

God packages are a common trap. You create pkg/common and dump everything there. Utilities, models, constants, helpers. It grows into a megapackage. Every file imports common. You get circular dependencies. The compiler stops you with import cycle not allowed if package A imports B and B imports A. Structure prevents this by separating concerns. Keep packages small and focused.

Another trap is leaking internals. You put implementation details in pkg or root packages. External code depends on them. You can't refactor. The fix is internal. Move private code there. The compiler enforces the boundary. You regain freedom.

Convention aside: context.Context always goes as the first parameter. Name it ctx. Functions that take a context should respect cancellation and deadlines. Run context through every long-lived call site. It's plumbing. Don't fight it.

The worst structure bug is the one that never logs. If you hide errors behind structure, you lose visibility. Return errors. Log them. Structure should make errors easy to propagate, not hard to find.

Decision matrix

Use a flat structure when the project is a single script or a small utility under 500 lines. Use a layered structure with cmd, internal, and pkg when building a service that needs clear boundaries between entry points, private logic, and shared libraries. Use domain-driven design with bounded contexts when the business logic is complex and spans multiple aggregates that evolve independently. Use a monolithic repository with multiple cmd directories when you have a suite of tools that share internal packages but produce different binaries.

Structure is a contract with your future self. The compiler is your architecture reviewer. Don't over-engineer. Start flat. Split when it hurts.

Where to go next