Project Layout & Architecture
Prerequisites: Benchmarking & profiling
Go has few rules about project structure, and that's deliberate. The tools enforce only a couple of conventions; the rest is judgement. The goal is code that's easy to navigate and change, not a layout that looks impressive.
Start small
A small tool or service can be a single main package:
myapp/
├── go.mod
├── main.go
├── handlers.go
└── handlers_test.go
Split into packages when the code gives you a reason — not on day one.
A typical service layout
As a service grows, a common shape is:
myapp/
├── go.mod
├── cmd/
│ ├── api/
│ │ └── main.go # HTTP server entry point
│ └── worker/
│ └── main.go # background job entry point
├── internal/
│ ├── orders/ # one package per feature/domain
│ │ ├── orders.go
│ │ ├── store.go
│ │ ├── http.go
│ │ └── orders_test.go
│ ├── users/
│ └── platform/ # shared infrastructure
│ ├── database/
│ └── httpx/
└── migrations/
cmd/<name>/— one directory per binary. Eachmain.gostays thin: read config, construct dependencies, start the program.internal/— the one layout rule the Go toolchain enforces. Packages underinternal/can only be imported by code rooted atinternal's parent, so other modules can't depend on your implementation details.pkg/— you'll see it in some projects as "public library code". It has no special meaning to the toolchain; most applications don't need it.
Package by feature, not by layer
Grouping by technical layer — models/, controllers/, services/,
repositories/ — means a single feature change touches four packages, and
those packages tend to end up importing each other in circles. Grouping by
feature keeps related code together:
internal/orders/ # Order type, validation, storage, HTTP handlers
internal/users/ # User type, validation, storage, HTTP handlers
Name packages for what they provide (orders, invoice, slug), not for
what they contain. Avoid util, common, and helpers — they become
dumping grounds with no clear purpose.
Accept interfaces, return structs
Constructors return concrete types; functions that need a dependency accept the smallest interface that describes what they use. Define that interface in the consumer's package, not next to the implementation:
package orders
import "context"
// Only the methods this package needs from a user lookup.
type UserFinder interface {
FindUser(ctx context.Context, id int64) (User, error)
}
type User struct {
ID int64
Email string
}
type Service struct {
users UserFinder
}
func NewService(users UserFinder) *Service {
return &Service{users: users}
}
The implementation can be a concrete store with twenty methods; orders
depends only on the one it calls, and tests can fake it in a few lines. Go
interfaces are satisfied implicitly, so the implementation doesn't need to
know the interface exists.
Wiring in main
Dependencies are constructed once in main and passed down explicitly — no
framework, no global state:
func main() {
cfg := loadConfig()
db := mustOpenDB(cfg.DatabaseURL)
userStore := users.NewStore(db)
orderSvc := orders.NewService(userStore)
mux := http.NewServeMux()
orders.RegisterRoutes(mux, orderSvc)
log.Fatal(http.ListenAndServe(cfg.Addr, mux))
}
If this function gets long, that's fine — it's the one place that knows how everything fits together.
Avoiding over-engineering
Patterns imported from other ecosystems often add ceremony without benefit in Go:
- An interface for every struct "for testability", each with one implementation. Add an interface when a second implementation (often a test fake) actually exists.
- Deep hexagonal/clean-architecture layering in a service with five endpoints.
- Dependency injection containers. Constructors and
maindo the job. - Getters and setters on every field. Export the field if callers need it.
Common mistakes
- Import cycles from layer-based packages. Go forbids cycles; it's usually a sign two packages belong together, or a shared type should move down.
- Business logic in
main.gothat tests can't reach. - Package names that stutter:
orders.OrderServicereads better asorders.Service. - Copying a large "standard layout" template into a project that has three files.
Practice
A reports package calls a *billing.Client that has 15 methods, but
reports only uses Invoices(ctx, month) ([]billing.Invoice, error).
Refactor reports so it can be tested without a real billing client.
Show solution
package reports
import (
"context"
"time"
"example.com/myapp/internal/billing"
)
// Defined here, in the consumer, with only what reports needs.
type InvoiceLister interface {
Invoices(ctx context.Context, month time.Month) ([]billing.Invoice, error)
}
type Generator struct {
invoices InvoiceLister
}
func NewGenerator(invoices InvoiceLister) *Generator {
return &Generator{invoices: invoices}
}
*billing.Client satisfies InvoiceLister automatically, so main passes
the real client unchanged, while tests pass a small fake whose Invoices
method returns fixed data.