{} The Go Reference

Arch structure · Architecture · Beginner

Go Project Layout

The architecture lens on Go repo structure — organizing by feature/domain (not technical layer) so packages map to bounded contexts, and where the domain↔infrastructure seam goes on disk.

Arch structure Beginner ⏱ 4 min read Complete

🏠 Analogy

A house organized “by material” — all the metal in one room, all the wood in another — is useless; you organize by function: a kitchen, a bathroom, a bedroom, each with the pipes and wiring it needs. Go code is the same. Folders named handlers/, models/, services/ scatter a single feature across the house; folders named order/, billing/ keep each feature whole. This page is about that architectural choice — how to slice — not the language mechanics.

🐹 The mechanics live in Fundamentals — this is the architecture lens

The how of Go layout — the cmd/ and internal/ conventions, package visibility by case, the no-import-cycles rule, go.mod/go.sum, and why the popular golang-standards/project-layout repo isn’t an official standard — is all covered in Fundamentals → Packages & modules. Read that for the rules. This page assumes them and focuses on the one decision that’s genuinely architectural: how you carve the tree so it mirrors your domain.

The one rule that’s architectural: slice by feature, not by layer

Given the mechanics, the design decision that matters is what your top-level packages represent. Two repos can use the exact same cmd/ + internal/ conventions and end up with opposite architectures:

graph TD
subgraph By["❌ by technical layer"]
  H["handlers/"] --- M["models/"] --- S["services/"] --- R["repos/"]
  note1["one 'order' feature is smeared<br/>across all four folders"]
end
subgraph Feat["✅ by feature / domain"]
  O["order/ — types + rules + repo iface"]
  B["billing/"]
  P["platform/ — db, http adapters"]
  note2["each package is a bounded context<br/>and a future service boundary"]
end

Layer-sliced (handlers/, models/, services/) feels tidy but scatters every feature across the whole tree, produces wide cross-package dependencies, and gives you no clean line to cut when you later want to extract a service. Feature-sliced (order/, billing/) keeps each bounded context cohesive in one package that owns its types, its rules, and the repository interface it needs.

Where the domain↔infrastructure seam goes on disk

The layout is your hexagonal architecture, expressed as directories:

myapp/
├── cmd/server/main.go       # composition root: wire & start (thin)
└── internal/
    ├── order/               # feature pkg = bounded context
    │   ├── order.go         #   domain types + rules
    │   └── repository.go    #   the repo INTERFACE (what the domain needs)
    ├── billing/
    └── platform/
        ├── postgres/        # adapters: IMPLEMENT the interfaces
        └── httpapi/         #   (import the driver here, never in order/)

The interface lives with the domain (order/repository.go); the implementation lives in infrastructure (platform/postgres). So the import arrow points inward — infrastructure depends on the domain, never the reverse. (The mechanics of why internal/ makes this a hard wall, and why cycles can’t form, are in Packages & modules.)

⚠️ Layout is a boundary decision — get the slicing right early

The cheap-to-change-now, expensive-to-change-later part of layout isn’t cmd/ vs pkg/ — it’s by-feature vs by-layer. A feature package that owns its data and exposes an interface is already a candidate service boundary; a layer-sliced tree has no clean line to cut and turns a future extraction into a rewrite. So slice by domain from the start, even in a tiny app — it costs nothing then and is painful to retrofit. (Everything else about growing the tree — start flat, add cmd//internal/ under pressure — is in Packages & modules.)

See also

Next: when those packages should stay together vs split apart — modular monolith vs microservices.

Check your understanding

Score: 0 / 3

1. Should packages be organized by technical layer (handlers/, models/, services/) or by feature/domain (order/, billing/)?

Slicing by technical layer (all handlers in one folder, all models in another) scatters a single feature across the tree and creates wide, cycle-prone dependencies. Organizing by feature/domain — an order package that owns the order's types, logic, and storage interface — keeps cohesion high and maps cleanly onto DDD bounded contexts. It's also what makes a later split into services tractable. Go's package-as-unit-of-encapsulation rewards domain-oriented packages.

2. In a feature package like order/, where should the repository INTERFACE live?

The dependency-inversion seam runs through the package layout: the feature/domain package declares the interface it needs (OrderRepository), and an infrastructure package (e.g. internal/platform/postgres) imports the driver and implements it. So the import arrow points inward — infrastructure depends on the domain, never the reverse. This is hexagonal architecture expressed as directory structure; see that page for the full rule.

3. Why does a clean by-feature layout make a later monolith→services split easier?

If a feature package owns its own types, logic, and data, and other packages reach it only through an interface, then that package is already shaped like a service. Lifting it out behind the same interface (now an HTTP/gRPC client) is a refactor of the wiring, not a redesign. A layer-sliced layout, by contrast, has no clean line to cut along — the 'order' logic is smeared across handlers/, models/, and services/. Layout decisions made early are boundary decisions you'll thank yourself for later.

Comments

Sign in with GitHub to join the discussion.