🏠 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
- Packages & modules (fundamentals) — the mechanics:
cmd/,internal/, cycles,go.mod, visibility. - Hexagonal & clean architecture — the boundaries this layout encodes.
- Domain-Driven Design — feature packages as bounded contexts.
- Modular monolith vs microservices — when these packages become services.
Next: when those packages should stay together vs split apart — modular monolith vs microservices.
Related topics
Ports & adapters and the dependency rule — keeping the domain free of I/O so the database, HTTP, and queues are swappable details. Demonstrated with Go interfaces.
arch-structureModular Monolith vs MicroservicesWhen to keep one deployable with clean internal module boundaries vs splitting into services — the real trade-offs, the distributed-monolith anti-pattern, and the strangler-fig path between them.
arch-principlesAggregates & RepositoriesClustering entities behind an aggregate root that guards invariants, and the repository interface that loads/saves whole aggregates — with persistence ignorance, demonstrated in runnable Go.
Check your understanding
Score: 0 / 31. 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.