Introduction
Most Go projects start as experiments: a main.go, a couple of helper functions, and suddenly you're running in production with thousands of requests per second. At that scale, project structure matters. Organizing a Go project requires deliberate choices—not the rigid frameworks of other languages, but a pragmatic layout that lets your team move fast without creating walls that prevent refactoring later. This article covers real-world lessons and examples from building and scaling production APIs, so you can structure your codebase to match where your project is today and where it might go tomorrow.
Start flat — one package is fine for small services
The biggest organizational mistake we see is premature structuring. When your service has one feature—say, an order API—putting it in pkg/orders/models.go, pkg/orders/handlers.go, pkg/orders/repository.go adds friction without benefit. You're not solving a problem yet.
Instead, start with everything in main.go or a single package. This is not a failure; it's the right call:
project/
├── main.go
├── go.mod
├── go.sum
└── README.mdEven a real production service can live here for weeks or months. Let's say you're building an order fulfillment API:
package main
import (
"database/sql"
"log"
"net/http"
"time"
)
type Order struct {
ID int64
CustomerID int64
Total float64
CreatedAt time.Time
}
func createOrder(w http.ResponseWriter, r *http.Request) {
// Handle the request
}
func getOrder(w http.ResponseWriter, r *http.Request) {
// Handle the request
}
func main() {
db, _ := sql.Open("postgres", "...")
defer db.Close()
http.HandleFunc("/orders", createOrder)
http.HandleFunc("/orders/{id}", getOrder)
log.Fatal(http.ListenAndServe(":8080", nil))
}This works. It's clear. A new team member can read the whole thing in five minutes. You know where the database logic is. When it's time to extract, you'll extract from a place that understands what you actually do. Don't anticipate structure you don't need yet.
The internal/ directory — guard your private code
The moment your service becomes a library, or other teams start importing your packages, you need boundaries. Go's internal/ directory is a language-level mechanism to prevent external imports.
Anything in internal/ cannot be imported by code outside your module. It's enforced by the compiler:
project/
├── main.go
├── internal/
│ ├── database/
│ │ └── orders.go
│ ├── handlers/
│ │ └── orders.go
│ └── auth/
│ └── token.go
├── go.mod
└── README.mdNow if another team imports your module and tries to use project/internal/database, the build fails immediately. Your API surface is explicit: only packages in the root or public subdirectories are fair game.
This matters when you publish a library or when multiple services depend on your core types. We built a billing service that teams across the company import for invoice schemas. Everything business logic went into internal/. Only the types and interfaces that others needed lived in the root:
// root package — public API
package billing
type Invoice struct {
ID string
Amount float64
DueDate time.Time
Status string
}
type Calculator interface {
ComputeTotal(items []LineItem) float64
}
// internal/calculator/compute.go — private implementation
package calculator
import "your-module/billing"
func (c *defaultCalculator) ComputeTotal(items []billing.LineItem) float64 {
// Complex logic: taxes, discounts, rounding
}Without internal/, other teams would find that private calculator and start using it. Six months later, you'd want to refactor it and break their code. internal/ prevents that coupling from forming.
Package by feature — not by type
This is where most Go projects improve the most. The common trap is organizing by technical layer:
// Anti-pattern: organized by type
project/
├── models/
│ ├── order.go
│ ├── user.go
│ └── billing.go
├── handlers/
│ ├── orders.go
│ ├── users.go
│ └── billing.go
├── repository/
│ ├── orders.go
│ ├── users.go
│ └── billing.go
└── main.goThis splits related code into three places. When you add a feature to orders—say, a new discount rule—you touch models/order.go, handlers/orders.go, and repository/orders.go. It's friction. It also makes it tempting to add "shared" logic to models/, which becomes a dumping ground.
Instead, organize by feature domain:
// Better: organized by feature
internal/
├── orders/
│ ├── order.go // types
│ ├── handlers.go // HTTP handlers
│ ├── repository.go // database queries
│ └── service.go // business logic
├── users/
│ ├── user.go
│ ├── handlers.go
│ ├── repository.go
│ └── service.go
├── billing/
│ └── ...
└── shared/ // Only cross-cutting: logging, errors, config
├── logger.go
├── errors.go
└── middleware.goNow when you work on orders, all the related code is in one place. Adding a new endpoint, adjusting validation, or changing the repository query—you're in the same folder. It's faster. It's also easier to move that feature to a separate service later if you need to.
Here's a concrete example: the internal/orders/orders.go file defines the domain:
package orders
import "time"
type Order struct {
ID int64
CustomerID int64
Total float64
Status string
CreatedAt time.Time
}
type CreateOrderRequest struct {
CustomerID int64 `json:"customer_id"`
Items []Item `json:"items"`
}
type Item struct {
ProductID int64 `json:"product_id"`
Quantity int32 `json:"quantity"`
Price float64 `json:"price"`
}Then internal/orders/service.go implements the logic:
package orders
import "context"
type Service struct {
repo Repository
// other dependencies
}
func (s *Service) Create(ctx context.Context, req CreateOrderRequest) (*Order, error) {
// Validation
if req.CustomerID == 0 {
return nil, ErrInvalidCustomer
}
// Calculate total
total := s.calculateTotal(req.Items)
// Persist
order := &Order{
CustomerID: req.CustomerID,
Total: total,
Status: "pending",
CreatedAt: time.Now(),
}
err := s.repo.Save(ctx, order)
if err != nil {
return nil, err
}
return order, nil
}
func (s *Service) calculateTotal(items []Item) float64 {
total := 0.0
for _, item := range items {
total += item.Price * float64(item.Quantity)
}
return total
}And internal/orders/handlers.go exposes the HTTP layer:
package orders
import (
"encoding/json"
"net/http"
)
type Handler struct {
svc *Service
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
order, err := h.svc.Create(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order)
}All related to orders. All in one place. Easy to test, easy to find, easy to move.
Avoid util packages — name packages by what they provide
Every project has a pkg/util/ or pkg/common/ where random functions accumulate. It's a smell. These packages have no clear purpose and become impossible to test or document.
Instead, name packages by the behavior they provide:
// Anti-pattern
internal/util/
├── util.go // Contains: StringToInt, TruncateString, ParseJSON, Retry...
// Better
internal/
├── retry/
│ └── retry.go
├── parser/
│ └── json.go
├── format/
│ └── strings.goEach package now has one reason to exist. Tests are clearer. Documentation is focused. When you import retry, it's obvious what you're getting.
That said, a small shared/ folder for logging, error types, and middleware is fine—but be strict. If it doesn't belong to multiple features, it shouldn't be there:
internal/shared/
├── errors.go // Domain error types: ErrNotFound, ErrConflict
├── logger.go // Logging setup
└── middleware.go // HTTP middleware: auth, logging, CORSThese are genuinely cross-cutting. Everything else lives with the feature that uses it.
Common mistakes
- Over-abstracting too early. We've seen teams build repository interfaces before their service talks to a database. Interfaces are great when you need them (testing, swapping implementations). Wait until you do.
- Mixing concerns in one file. If a file is over 500 lines, split it. A file should do one thing: define types, or implement handlers, or manage the repository. Not all three.
- Making public packages that should be internal. Every public export increases surface area. Keep things in
internal/by default. Export only what external code needs. - Creating deep hierarchies. Don't nest packages beyond
internal/feature/subfeature/. If you're more than two levels deep, consider a flat structure within the feature. Go's flat package model rewards simplicity. - Ignoring naming when extracting. If you pull code into a new package, the package name should reflect what it does.
internal/orders/validationis better thaninternal/validationif it's only used by orders. Specificity prevents premature generalization.
Key takeaways
- Start with a flat structure—one package is production-ready. Avoid structure you don't yet need.
- Use
internal/to enforce boundaries once your code becomes multi-package or multi-team. - Organize by feature domain (orders, users, billing), not by technical layer (models, handlers, repository). It keeps related code together and makes refactoring safer.
- Name packages by what they provide, not what type of code they contain.
retryis better thanutil.parseris better thancommon. - A small
shared/orinternal/folder for cross-cutting logic is fine—but be strict. If it's specific to one feature, it lives with that feature. - Keep packages shallow. Avoid deep nesting. Go's flat model is an asset; use it.






Comments
Loading comments…