AlgoMaster Logo

Project Layout and Structure

Medium Priority21 min readUpdated June 6, 2026

Go is opinionated about formatting and naming but deliberately quiet on folder structure. There's no mvc/-style scaffold, no framework-imposed tree, and the official guidance fits on a single page. That freedom is great until a project grows past a handful of files and the team starts arguing about where Order should live. This chapter walks through how real Go codebases evolve, what the language enforces (the internal/ rule), what it suggests (cmd/ for binaries), and what's pure convention (everything else).

Start Flat, Stay Flat as Long as You Can

Most Go projects start with a single main.go and grow from there. That's not a beginner's layout, it's the right layout. The standard library itself uses a flat-per-package structure: net/http is one directory with dozens of files, no handlers/, no models/, no services/.

A small e-commerce checkout tool might look like this:

Everything compiles into one binary. Every file is package checkout except main.go, which is package main. The reader can open any file and immediately see how it relates to the others. You don't need a folder tree to express six files.

The trap is reaching for internal/handlers/, internal/services/, internal/repositories/ before the project earns it. A 400-line program split across eight directories is harder to read, not easier. Adding structure has a cost: every import is longer, every cross-file change traverses more folders, and the reader has to hold a mental model of the tree.

So when do you actually split?

Signals You've Outgrown Flat

Three signals matter, and they show up in this order:

  1. You're building more than one binary. A web server plus a worker plus a migration tool all in package main no longer fits, because each main needs its own directory.
  2. You want to enforce privacy across files. Go's only visibility levels are package-public (uppercase) and package-private (lowercase). To make code that's "private to this app", you need a separate package and the internal/ rule.
  3. The package has unrelated concerns. A cart package that also handles email notifications and PDF receipts is doing too much. Splitting it by domain (cart, notifications, receipts) makes each piece testable in isolation.

None of these are about line count. A 5,000-line single-package library can be perfectly readable if every file is about the same thing. Splitting it just to split it makes things worse.

cmd/ for Entry Points

Once you have more than one binary, the standard arrangement is a top-level cmd/ folder with one subdirectory per executable. Each subdirectory holds exactly one main.go (and maybe a main_test.go, plus any flags or wiring specific to that binary).

Build each binary by pointing go build at its directory:

The output binaries are named after the directory (api, worker, migrate), which is exactly what you want.

The rule for cmd/ subdirectories is "thin wiring, no business logic". Each main.go parses flags or config, opens the database, constructs the dependency graph, and calls into the real packages under internal/. If you copy a main.go to another project, it shouldn't carry any product knowledge with it.

The handlers, stores, and services all live elsewhere. main.go is a recipe, not a dish.

Putting business logic in cmd/api/main.go makes it untestable. The testing package can't import package main. If you need tests, the code belongs in an importable package under internal/.

internal/ Is the Real Privacy Boundary

internal/ is the one folder name Go's compiler treats specially. Any package whose import path includes /internal/ can only be imported by packages that share an ancestor with the internal/ directory.

That sounds abstract. Concretely: if you have github.com/example/shop/internal/order, only packages under github.com/example/shop/... can import it. github.com/some/other/repo cannot. The Go tool will refuse, with a compile error.

The diagram shows the rule: anything inside shop/cmd/... is a descendant of shop/, and shop/internal/order is also a descendant of shop/, so they share the ancestor shop/. An external repo doesn't share that ancestor, so the import fails.

The error looks like this:

This is a real boundary, not a naming convention. You can move cart from internal/cart to pkg/cart to make it public, or back, without changing any code. The directory move is the only thing that changes the visibility.

A common e-commerce skeleton:

Everything under internal/ is private to the shop module. The internal/platform/ subtree is a common convention for code that's reusable across packages within the project but still not for external consumption: database helpers, HTTP middleware, logging setup.

pkg/ Is Controversial: Use It Sparingly

pkg/ is a convention some Go projects adopt to hold code that's intentionally public, the opposite of internal/. The idea: if internal/ is for private code, pkg/ is for code other repos may import.

The Go team doesn't recommend it. Their stance is roughly "you don't need a folder to say a package is public, the absence of internal/ already does that". Many large projects (Kubernetes, Helm, Prometheus) use pkg/ anyway, and many (the Go standard library, Hugo, esbuild) don't.

The case for pkg/:

  • It physically separates the public API surface from the internal one. A reader scanning the repo sees what's offered as a library.
  • It groups everything an external consumer might import, which helps when the project is both a binary and a library.
  • It plays well with monorepos that publish multiple Go modules from a single repository.

The case against:

  • Every public package can already be told apart from internal ones by the absence of internal/ in its path.
  • Imports become longer: github.com/example/shop/pkg/sdk rather than github.com/example/shop/sdk.
  • It encourages over-categorization. People start adding pkg/util/, pkg/common/, which are exactly the kinds of grab-bag packages Go style discourages.

Pragmatic rule: if your project is a binary first and a library never, skip pkg/. If it's a library that also ships a CLI, putting the library code under pkg/ and the CLI under cmd/ makes the relationship visible. If you're not sure, leave it out. You can always add it later, and removing it is just git mv.

Layout choiceWhen it fits
No pkg/, packages at module rootThe project is primarily a binary; library use is incidental.
pkg/ for public packagesThe project is a library with a CLI on the side, or publishes a public SDK.
internal/ only, no pkg/Internal services where nothing is meant to be imported by other repos.

Domain-Driven vs Layer-Based Folders

Once you have multiple packages under internal/, you have to decide how to slice them. Two camps:

Layer-based. Folders named after architectural layers: internal/handlers/, internal/services/, internal/repositories/, internal/models/. Every domain (cart, order, product) has files scattered across all four folders. Familiar to people coming from frameworks like Spring or Rails.

Domain-based. Folders named after business concepts: internal/cart/, internal/order/, internal/product/. Each folder contains its own handlers, services, and storage code. Familiar to people who've worked with hexagonal architecture or "package by feature".

Go community style overwhelmingly favors the domain layout. Three reasons.

First, Go's package import graph is acyclic. If handlers imports services imports repositories (the typical layered direction), every package has a fixed slot. Adding a new feature touches three folders. With a domain layout, a new feature is usually one folder.

Second, the package name shows up in the call site. cart.Handler(...) reads better than handlers.CartHandler(...). The domain layout gives every type a meaningful prefix without you having to repeat the domain in the type name.

Third, layer-based folders breed "models" packages that depend on nothing and have everything depend on them. They turn into shared mutable state at the type level. The domain layout puts the Cart struct in cart, the Order struct in order, and lets each package own its shape.

Compare to the layered version of the same five-file unit, spread across four folders, each touching every domain. The domain layout wins on readability for any project that isn't a one-trick CRUD app.

That said, the layered shape isn't always wrong. A pure data pipeline, where every stage transforms the same shape and the "domains" are stages of processing, can read well with layer-named folders. But for a typical web service, default to domain.

Layer-based layouts make cross-cutting changes (adding a new field to Cart) touch four folders. Domain layouts make them touch one. The savings compound across years of changes.

api/, web/, migrations/, scripts/, configs/

Beyond Go source code, real projects accumulate companion files. The community has settled on a handful of folder names for them. None of these are enforced by the toolchain. They're conventions, and most teams adopt them because tools and CI systems look for them automatically.

`api/` holds the contract: protobuf .proto files, OpenAPI specs, JSON schemas, GraphQL definitions. These are the source of truth for what your service accepts and returns. Generators read them and emit Go code (often into internal/<service>/gen/).

`web/` holds static assets and templates served by the binary: HTML templates, CSS, JS bundles, images. Common subfolders are web/templates/ and web/static/. Some projects use static/ directly at the root instead. Either works.

`migrations/` holds database schema migrations as numbered SQL files. Tools like golang-migrate and goose expect this exact name by default.

`scripts/` holds operational shell scripts: dev.sh to start a local environment, seed.sh to load fixture data, release.sh to cut a tag. Anything you want a teammate to run from the project root.

`configs/` holds default config files in their native format (YAML, TOML, JSON). Production configs typically live elsewhere (a secrets manager, Kubernetes manifests), but configs/dev.yaml and configs/local.example.yaml make local setup obvious.

A reasonable e-commerce repo layout pulling these together:

Most of this is convention, but it's such common convention that anyone joining the project can navigate it on the first day. That's the real payoff.

What golang-standards/project-layout Gets Right (and Wrong)

There's a popular GitHub repo called golang-standards/project-layout that catalogs every folder name commonly seen in a Go project. It lists cmd/, internal/, pkg/, api/, web/, configs/, scripts/, build/, deployments/, test/, docs/, tools/, examples/, third_party/, githooks/, assets/, website/, and a few others.

What it gets right:

  • The list of common folders is useful as a checklist.
  • The descriptions of internal/ and cmd/ are accurate.
  • It documents conventions that are widely adopted.

What it gets wrong (and the Go team has pushed back on):

  • The repo isn't an official standard. The Go team has explicitly said it isn't endorsed.
  • Most projects need maybe six of those folders, not all seventeen. Treating the list as a starter template produces ghost-town directories that confuse new contributors.
  • The pkg/ recommendation is presented more confidently than the community consensus warrants.

Use the repo as a reference when you're naming a new folder ("does the community have a convention for this?"), not as a scaffold. Start with cmd/ and internal/, add other folders only when you have files that genuinely belong in them.

go.mod and Module Roots

A Go module is a tree of packages rooted at a go.mod file. The directory containing go.mod is the module root, and everything below it (until another go.mod if there is one) is part of that module. The first line of go.mod declares the module path:

That module line is the prefix every package in the module gets imported by. So internal/cart/cart.go is imported as github.com/example/shop/internal/cart, and cmd/api/main.go is imported as github.com/example/shop/cmd/api.

Place `go.mod` at the project root, not inside a subdirectory. Putting it in src/go.mod or app/go.mod makes every import path uglier and breaks tooling that assumes the standard layout.

You only need one go.mod per module. Subdirectories don't need their own. If you want to publish a single repo as multiple modules, that's a different decision (covered next).

The file's location (internal/cart/cart.go) plus the module path (github.com/example/shop) together produce the import path. There's no separate registration step.

Renaming a module (the module line in go.mod) is a breaking change for every importer, internal and external. Pick the path carefully on day one. For a personal project, github.com/<you>/<repo> is the safe default.

Monorepo vs Multi-Module: When to Use go.work

For a single product, one go.mod at the root covers every binary and package. That's the common case and the simplest setup.

A monorepo with several independent products is different. Each product may need its own release cycle, its own dependencies, and its own go.mod. Go 1.18 introduced workspaces (go.work) to make this manageable.

A workspace is a file at the root of the repo that lists the modules inside it. The Go tool resolves imports across all listed modules as if they were one tree, even though each module still has its own go.mod.

The go.work file at the root looks like this:

With the workspace in place, code in services/checkout can import github.com/example/monorepo/libs/sharedlogger and the Go tool resolves it to the local directory rather than fetching a published version. The published version still matters: when services/checkout is built and deployed in isolation (outside the workspace), it pulls sharedlogger from the module registry like any other dependency.

The diagram shows the two resolution modes. Inside the workspace (during development), changes to sharedlogger are immediately visible to checkout without publishing a new version. Outside the workspace (in CI or production builds for a single service), each module behaves as an independent dependency.

When to use go.work:

  • The repository contains multiple services that share libraries, and you want changes to those libraries to be immediately visible across services.
  • You're in the middle of splitting a single module into several and want the in-flight refactor to keep compiling.
  • You depend on a fork of an external module that you also maintain in this repo.

When not to use multiple modules:

  • The repo has one product, one team, one release. One go.mod is simpler.
  • You're tempted to add a module per package "for cleanliness". Modules add real overhead (versioning, replace directives, CI complexity). Use packages, not modules, for code organization within a single product.

A Realistic E-Commerce Layout

Pulling everything together, here's a layout for a mid-sized e-commerce backend with a public HTTP API, a background worker that processes orders, and a CLI for running migrations:

What this layout deliberately does not have:

  • A models/ or entities/ package. Each domain owns its own types.
  • A utils/ or common/ package. Cross-cutting code lives in internal/platform/... with a specific name (httpx, logging, db).
  • A pkg/ folder. Nothing in this repo is meant to be imported by external code, so internal/ covers it.
  • A separate module per service. One product, one module.

The dependency arrows tell a story. cmd/ packages depend on the domain packages and the platform packages but not on each other. Domain packages depend on platform but not on each other (an order can ask for a cart's contents through an interface, but the order package doesn't import cart directly if you can avoid it). Platform packages depend on standard library and external dependencies only. The result is a graph where you can change cart without touching order, and you can change either without touching httpx.

Common Layout Mistakes

A few patterns show up repeatedly in Go projects, and they're worth flagging because each one looks reasonable until it bites.

A `models/` package shared by everyone. This is the layered-architecture instinct. The problem: every domain ends up importing it, and a small change to one type forces every dependent package to recompile and re-review. Worse, the Cart type's behavior gets scattered across services/cart.go, handlers/cart.go, and models/cart.go. Domain layouts put Cart and its methods in the same package, which is easier to understand and to change.

A `util/` or `common/` package. Same trap, different label. util means "code I didn't know where else to put". Two months later it's a 2,000-line file that imports half the standard library. Better: name the helper after what it does. String escaping goes in internal/stringx. HTTP middleware goes in internal/httpx. Time helpers go in internal/timex. The x suffix is a Go convention for "utilities for the standard library package of this name".

Test files in a separate `tests/` folder. Go expects test files next to the code they test, in the same package. Moving them to tests/ means they can't see unexported identifiers, and go test./internal/cart no longer picks them up. The standard layout (internal/cart/cart_test.go) is the right one.

`go.mod` in a subdirectory. Some projects put source code under src/ or app/ and the go.mod there. Every import path then has to include src/, which is ugly and confuses tools. Put go.mod at the repository root.

A package per file. Splitting cart.go and cart_handler.go into separate packages (cart and carthandler) doesn't add clarity, it adds import cycles and overhead. A package is the right unit of cohesion; files within it can split freely.

MistakeWhy it hurtsFix
internal/models/ shared by all domainsEvery change touches every packagePut types with their behavior, in a domain package
internal/util/Grows into a grab bagName helpers after their purpose (httpx, timex)
tests/ folderHides unexported access, breaks go test./...Tests next to code, same package
go.mod in src/Ugly imports, broken toolsPut go.mod at the repo root
One package per fileImport cycles and noiseOne package per concept, multiple files

Quiz

Project Layout Quiz

10 quizzes