In the previous tutorial, you learned about pointers. Now it is time to learn how to organize a Go project properly.
Good project structure makes your code easy to read, test, and maintain. Go does not force a specific layout, but the community has developed clear conventions. This tutorial teaches you those conventions.
Starting Simple — One Package
When your project is small, keep it simple. A single main package is fine:
my-app/
go.mod
main.go
// main.go
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
Many beginners think they need a complex folder structure from day one. They don’t. Start simple and add structure when the code gets bigger.
The Standard Go Project Layout
As your project grows, this is the recommended structure:
my-api/
cmd/
api/
main.go # Entry point for the API server
worker/
main.go # Entry point for a background worker
internal/
user/
handler.go # HTTP handlers
service.go # Business logic
repository.go # Database access
model.go # Data types
order/
handler.go
service.go
repository.go
model.go
config/
config.go # Configuration loading
pkg/
validator/
validator.go # Reusable validation utilities
go.mod
go.sum
Let’s look at each directory.
cmd/ — Entry Points
The cmd/ directory contains the entry points for your application. Each subdirectory is a separate binary:
// cmd/api/main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
fmt.Println("Starting API server on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
// cmd/worker/main.go
package main
import "fmt"
func main() {
fmt.Println("Starting background worker")
// Process jobs from a queue...
}
Build them separately:
go build ./cmd/api
go build ./cmd/worker
If your project has only one binary, you can put main.go in the project root and skip cmd/ entirely.
internal/ — Private Packages
The internal/ directory contains packages that only your project can use. Go enforces this. Other projects cannot import from your internal/ directory:
// internal/user/model.go
package user
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// internal/user/repository.go
package user
import "errors"
var ErrNotFound = errors.New("user not found")
type Repository interface {
FindByID(id int) (*User, error)
FindAll() ([]User, error)
Create(user *User) error
Update(user *User) error
Delete(id int) error
}
The internal/ directory is your main working area. Most of your code goes here.
pkg/ — Reusable Packages
The pkg/ directory contains packages that other projects can import. If you don’t plan to share code, skip pkg/ entirely:
// pkg/validator/validator.go
package validator
import "regexp"
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
func IsValidEmail(email string) bool {
return emailRegex.MatchString(email)
}
func IsNotEmpty(s string) bool {
return len(s) > 0
}
Note: Many Go developers skip pkg/ and put reusable code in internal/ or in top-level packages. The pkg/ directory is optional.
Clean Architecture Layers
Clean architecture separates your code into layers. Each layer has a specific job:
- Model — Data types (structs)
- Repository — Database access
- Service — Business logic
- Handler — HTTP request handling
Here is a complete example:
Model Layer
// internal/user/model.go
package user
import "time"
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
type CreateRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
Repository Layer
The repository handles all database operations. It uses an interface so you can swap the implementation (real database, in-memory, mock):
// internal/user/repository.go
package user
import (
"errors"
"sync"
)
var ErrNotFound = errors.New("user not found")
type Repository interface {
FindByID(id int) (*User, error)
FindAll() ([]User, error)
Create(user *User) error
Delete(id int) error
}
// In-memory implementation (for learning — replace with database later)
type MemoryRepository struct {
mu sync.RWMutex
users map[int]*User
nextID int
}
func NewMemoryRepository() *MemoryRepository {
return &MemoryRepository{
users: make(map[int]*User),
nextID: 1,
}
}
func (r *MemoryRepository) FindByID(id int) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
user, ok := r.users[id]
if !ok {
return nil, ErrNotFound
}
return user, nil
}
func (r *MemoryRepository) FindAll() ([]User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]User, 0, len(r.users))
for _, u := range r.users {
users = append(users, *u)
}
return users, nil
}
func (r *MemoryRepository) Create(user *User) error {
r.mu.Lock()
defer r.mu.Unlock()
user.ID = r.nextID
r.nextID++
r.users[user.ID] = user
return nil
}
func (r *MemoryRepository) Delete(id int) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.users[id]; !ok {
return ErrNotFound
}
delete(r.users, id)
return nil
}
Service Layer
The service contains business logic. It uses the repository interface, not the concrete implementation:
// internal/user/service.go
package user
import (
"errors"
"time"
)
var (
ErrEmptyName = errors.New("name cannot be empty")
ErrEmptyEmail = errors.New("email cannot be empty")
)
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) GetUser(id int) (*User, error) {
return s.repo.FindByID(id)
}
func (s *Service) ListUsers() ([]User, error) {
return s.repo.FindAll()
}
func (s *Service) CreateUser(req CreateRequest) (*User, error) {
// Validate
if req.Name == "" {
return nil, ErrEmptyName
}
if req.Email == "" {
return nil, ErrEmptyEmail
}
user := &User{
Name: req.Name,
Email: req.Email,
CreatedAt: time.Now(),
}
if err := s.repo.Create(user); err != nil {
return nil, err
}
return user, nil
}
func (s *Service) DeleteUser(id int) error {
return s.repo.Delete(id)
}
Handler Layer
The handler translates HTTP requests to service calls:
// internal/user/handler.go
package user
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) HandleGetUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "invalid user ID", http.StatusBadRequest)
return
}
user, err := h.service.GetUser(id)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
func (h *Handler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.service.ListUsers()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func (h *Handler) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
user, err := h.service.CreateUser(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
func (h *Handler) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "invalid user ID", http.StatusBadRequest)
return
}
if err := h.service.DeleteUser(id); err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /users", h.HandleListUsers)
mux.HandleFunc("GET /users/{id}", h.HandleGetUser)
mux.HandleFunc("POST /users", h.HandleCreateUser)
mux.HandleFunc("DELETE /users/{id}", h.HandleDeleteUser)
fmt.Println("Registered user routes")
}
Dependency Injection — Wiring It All Together
Dependency injection means passing dependencies from the outside instead of creating them inside the component. In Go, you do this without a framework:
// cmd/api/main.go
package main
import (
"fmt"
"log"
"net/http"
"github.com/kemalcodes/go-tutorial/internal/user"
)
func main() {
// Create dependencies from bottom to top
userRepo := user.NewMemoryRepository() // 1. Repository
userService := user.NewService(userRepo) // 2. Service (depends on repo)
userHandler := user.NewHandler(userService) // 3. Handler (depends on service)
// Register routes
mux := http.NewServeMux()
userHandler.RegisterRoutes(mux)
// Start server
fmt.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
This is the main advantage of the layered approach:
- To switch from in-memory to PostgreSQL, you only change the repository. The service and handler stay the same.
- To test the service, you pass a mock repository. No real database needed.
- Each layer only knows about the layer below it.
Configuration Management
A clean way to handle configuration:
// internal/config/config.go
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Port int
Host string
DBHost string
DBPort int
DBName string
LogLevel string
}
func Load() (*Config, error) {
port, err := strconv.Atoi(getEnv("PORT", "8080"))
if err != nil {
return nil, fmt.Errorf("invalid PORT: %w", err)
}
dbPort, err := strconv.Atoi(getEnv("DB_PORT", "5432"))
if err != nil {
return nil, fmt.Errorf("invalid DB_PORT: %w", err)
}
return &Config{
Port: port,
Host: getEnv("HOST", "0.0.0.0"),
DBHost: getEnv("DB_HOST", "localhost"),
DBPort: dbPort,
DBName: getEnv("DB_NAME", "myapp"),
LogLevel: getEnv("LOG_LEVEL", "info"),
}, nil
}
func getEnv(key, defaultValue string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return defaultValue
}
Use it in main.go:
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
log.Fatal(http.ListenAndServe(addr, mux))
When to Split Into Packages
Here is a simple guide:
Keep it in one package when:
- The code is small (under 500 lines)
- All the code is related to one concept
- You are just starting the project
Split into packages when:
- Different parts of the code serve different purposes
- You want to control what is exported (public API)
- The package is getting hard to navigate
Package naming rules:
- Short, lowercase, one word:
user,config,auth - No underscores, no mixedCaps:
userservicenotuser_service - The package name should describe what the package does, not what it contains
- Avoid generic names like
util,common,helpers
// Good package names
package user // Handles user domain
package auth // Handles authentication
package config // Handles configuration
package middleware // HTTP middleware
// Bad package names
package utils // Too generic
package helpers // Too generic
package common // Too generic
package models // Describes contents, not purpose
Comparison with Other Languages
| Concept | Go | Java/Spring | Rust |
|---|---|---|---|
| Entry point | cmd/ directory | Application.java | src/main.rs |
| Private code | internal/ | Package-private classes | pub(crate) visibility |
| Shared code | pkg/ | Shared modules | Public crate API |
| DI framework | None needed | Spring IoC container | None (manual) |
| Interface location | Same package as consumer | Separate interface packages | Trait definitions in their own module |
| Build | go build ./... | mvn package or gradle build | cargo build |
Go’s approach is simpler. No framework needed for dependency injection. No annotations. Just functions that accept interfaces and return structs.
A Complete Mini-Project
Here is a minimal but complete project structure you can use as a starting point:
notes-api/
cmd/
api/
main.go
internal/
note/
model.go
repository.go
service.go
handler.go
config/
config.go
go.mod
The main.go wires everything together:
// cmd/api/main.go
package main
import (
"fmt"
"log"
"net/http"
"github.com/kemalcodes/go-tutorial/internal/config"
"github.com/kemalcodes/go-tutorial/internal/note"
)
func main() {
// Load config
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config:", err)
}
// Wire dependencies
noteRepo := note.NewMemoryRepository()
noteService := note.NewService(noteRepo)
noteHandler := note.NewHandler(noteService)
// Register routes
mux := http.NewServeMux()
noteHandler.RegisterRoutes(mux)
// Start server
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
fmt.Printf("Server starting on %s\n", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
This structure scales well. As your project grows, you add more packages under internal/. The cmd/ directory stays thin — it only wires things together.
Common Mistakes
1. Over-engineering from the start.
// Don't do this for a small project
my-app/
cmd/api/main.go
internal/domain/entity/user.go
internal/domain/valueobject/email.go
internal/application/usecase/createuser.go
internal/infrastructure/persistence/postgres/user.go
internal/infrastructure/http/handler/user.go
internal/infrastructure/http/middleware/auth.go
...
Start simple. Add structure when you need it, not before.
2. Circular imports.
// package user imports package order
// package order imports package user
// This does NOT compile in Go
Go does not allow circular imports. If two packages need each other, extract the shared types into a third package or use interfaces.
3. Putting everything in package main.
// A 2000-line main.go is hard to read and test
package main
func main() {
// ... everything here
}
Keep main.go thin. It should only create dependencies and start the application. Put all logic in separate packages under internal/.
Source Code
You can find the complete source code for this tutorial on GitHub:
Related Articles
- Go Tutorial #9: Pointers — Pointers and memory management
- Go Tutorial #8: Interfaces and Polymorphism — Interfaces for clean architecture
What’s Next?
In the next tutorial, Go Tutorial #11: Goroutines, you will learn:
- What goroutines are — Go’s lightweight concurrency
- Starting goroutines with the
gokeyword sync.WaitGroupfor waiting on goroutinessync.Mutexfor safe concurrent access- The race detector
This is where Go gets exciting. Concurrency is Go’s killer feature.
This is part 10 of the Go Tutorial series. Follow along to learn Go from scratch.