Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 023 / 100

Organizing a Go project: real-world lessons and examples

Structure that scales without over-engineering.

Expona Admin8 min read

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:

go
project/
├── main.go
├── go.mod
├── go.sum
└── README.md

Even a real production service can live here for weeks or months. Let's say you're building an order fulfillment API:

go
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:

go
project/
├── main.go
├── internal/
│   ├── database/
│   │   └── orders.go
│   ├── handlers/
│   │   └── orders.go
│   └── auth/
│       └── token.go
├── go.mod
└── README.md

Now 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:

go
// 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:

go
// 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.go

This 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:

go
// 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.go

Now 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:

go
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:

go
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:

go
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:

go
// Anti-pattern
internal/util/
├── util.go  // Contains: StringToInt, TruncateString, ParseJSON, Retry...

// Better
internal/
├── retry/
│   └── retry.go
├── parser/
│   └── json.go
├── format/
│   └── strings.go

Each 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:

go
internal/shared/
├── errors.go       // Domain error types: ErrNotFound, ErrConflict
├── logger.go       // Logging setup
└── middleware.go   // HTTP middleware: auth, logging, CORS

These 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/validation is better than internal/validation if 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. retry is better than util. parser is better than common.
  • A small shared/ or internal/ 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.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles